fix: contact name wiped on refresh, migrate to paymentHash instead of checkingId
This commit is contained in:
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import com.bitcointxoko.gudariwallet.data.db.*
|
import com.bitcointxoko.gudariwallet.data.db.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class ContactRepository(app: Application) {
|
class ContactRepository(app: Application) {
|
||||||
private val dao = AppDatabase.getInstance(app).contactDao()
|
private val dao = AppDatabase.getInstance(app).contactDao()
|
||||||
@@ -125,15 +126,17 @@ class ContactRepository(app: Application) {
|
|||||||
|
|
||||||
// --- Contact-Tx link ---
|
// --- Contact-Tx link ---
|
||||||
|
|
||||||
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
|
||||||
dao.observeContactsForPayment(checkingId)
|
dao.observeContactsForPayment(paymentHash)
|
||||||
|
|
||||||
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
|
suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
|
||||||
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
|
Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
|
||||||
dao.unlinkPaymentFromContact(checkingId, contactId)
|
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.unlinkPaymentFromContact(paymentHash, contactId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
||||||
@@ -141,11 +144,20 @@ class ContactRepository(app: Application) {
|
|||||||
|
|
||||||
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
||||||
dao.observePaymentContactNames()
|
dao.observePaymentContactNames()
|
||||||
.map { list -> list.associate { it.checkingId to it.contactName } }
|
.map { list ->
|
||||||
|
list.associate { it.paymentHash to it.contactName }
|
||||||
|
.also { map ->
|
||||||
|
Timber.d(
|
||||||
|
"CONTACT [MAP EMIT ] ${map.size} entries: ${
|
||||||
|
map.entries.take(5).joinToString { "${it.key.take(8)}→${it.value}" }
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactName(query: String): List<String> =
|
suspend fun findPaymentHashesByContactName(query: String): List<String> =
|
||||||
dao.findCheckingIdsByContactName(query)
|
dao.findPaymentHashesByContactName(query)
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
|
||||||
dao.findCheckingIdsByContactIds(contactIds)
|
dao.findPaymentHashesByContactIds(contactIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,33 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
if (payments.isEmpty()) return
|
if (payments.isEmpty()) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
runCatching {
|
runCatching {
|
||||||
dao.insertAll(payments.map { it.toEntity(now) })
|
payments.forEach { payment ->
|
||||||
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
val e = payment.toEntity(now)
|
||||||
|
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Persist the first page of payments to the database. */
|
/** Persist the first page of payments to the database. */
|
||||||
suspend fun savePayments(payments: List<PaymentRecord>) {
|
suspend fun savePayments(payments: List<PaymentRecord>) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -130,8 +150,24 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
|
|
||||||
/** Upsert PaymentRecord to database. */
|
/** Upsert PaymentRecord to database. */
|
||||||
suspend fun upsertPayment(payment: PaymentRecord) {
|
suspend fun upsertPayment(payment: PaymentRecord) {
|
||||||
val entity = payment.toEntity(savedAt = System.currentTimeMillis())
|
val e = payment.toEntity(savedAt = System.currentTimeMillis())
|
||||||
dao.insert(entity)
|
Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +202,11 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
* contact ID filter. Amount/date/status are applied in SQL;
|
* contact ID filter. Amount/date/status are applied in SQL;
|
||||||
* direction and type are handled in-memory afterwards (same as queryAll).
|
* direction and type are handled in-memory afterwards (same as queryAll).
|
||||||
*/
|
*/
|
||||||
suspend fun queryByCheckingIds(
|
suspend fun queryByPaymentHashes(
|
||||||
checkingIds : List<String>,
|
paymentHashes : List<String>,
|
||||||
filter : PaymentFilter
|
filter : PaymentFilter
|
||||||
): List<PaymentRecord> {
|
): List<PaymentRecord> {
|
||||||
if (checkingIds.isEmpty()) return emptyList()
|
if (paymentHashes.isEmpty()) return emptyList()
|
||||||
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
|
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
|
||||||
filter.statuses.flatMap { s ->
|
filter.statuses.flatMap { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -180,8 +216,8 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}.joinToString(",")
|
}.joinToString(",")
|
||||||
}
|
}
|
||||||
return dao.getByCheckingIdsFiltered(
|
return dao.getByPaymentHashesFiltered(
|
||||||
checkingIds = checkingIds,
|
paymentHashes = paymentHashes,
|
||||||
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
|
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
|
||||||
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
|
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
|
||||||
minCreatedAt = filter.minCreatedAt,
|
minCreatedAt = filter.minCreatedAt,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
PaymentAddressEntity::class,
|
PaymentAddressEntity::class,
|
||||||
TxContactCrossRef::class,
|
TxContactCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 9,
|
version = 10,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(PaymentConverters::class)
|
@TypeConverters(PaymentConverters::class)
|
||||||
@@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// 1. Create replacement table — same schema minus the payment_records FK
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
// 2. Copy existing rows
|
||||||
|
database.execSQL("""
|
||||||
|
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
|
||||||
|
SELECT checkingId, contactId, role FROM tx_contact_links
|
||||||
|
""")
|
||||||
|
// 3. Swap tables
|
||||||
|
database.execSQL("DROP TABLE tx_contact_links")
|
||||||
|
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase =
|
fun getInstance(context: Context): AppDatabase =
|
||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
MIGRATION_6_7,
|
MIGRATION_6_7,
|
||||||
MIGRATION_7_8,
|
MIGRATION_7_8,
|
||||||
MIGRATION_8_9
|
MIGRATION_8_9,
|
||||||
|
MIGRATION_9_10
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
|
|||||||
@@ -76,51 +76,51 @@ interface ContactDao {
|
|||||||
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
||||||
|
|
||||||
// Delete a link
|
// Delete a link
|
||||||
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
|
||||||
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
|
||||||
|
|
||||||
// Delete all contact links for a payment
|
// Delete all contact links for a payment
|
||||||
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
|
||||||
suspend fun clearContactsForPayment(checkingId: String)
|
suspend fun clearContactsForPayment(paymentHash: String)
|
||||||
|
|
||||||
// Get all contacts linked to a payment
|
// Get all contacts linked to a payment
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT c.* FROM contacts c
|
SELECT c.* FROM contacts c
|
||||||
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
||||||
WHERE tcl.checkingId = :checkingId
|
WHERE tcl.paymentHash = :paymentHash
|
||||||
""")
|
""")
|
||||||
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
// Get all payments linked to a contact
|
// Get all payments linked to a contact
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT checkingId FROM tx_contact_links
|
SELECT paymentHash FROM tx_contact_links
|
||||||
WHERE contactId = :contactId
|
WHERE contactId = :contactId
|
||||||
ORDER BY createdAt DESC
|
ORDER BY createdAt DESC
|
||||||
""")
|
""")
|
||||||
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
||||||
|
|
||||||
// Returns all (checkingId, displayName) pairs that have a linked contact,
|
// Returns all (paymentHash, displayName) pairs that have a linked contact,
|
||||||
// so HistoryViewModel can build its lookup map in a single reactive query.
|
// so HistoryViewModel can build its lookup map in a single reactive query.
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT tcl.checkingId AS checkingId,
|
SELECT tcl.paymentHash AS paymentHash,
|
||||||
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
||||||
FROM tx_contact_links tcl
|
FROM tx_contact_links tcl
|
||||||
INNER JOIN contacts c ON c.id = tcl.contactId
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
""")
|
""")
|
||||||
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
||||||
|
|
||||||
// For text search matching contact names — returns checkingIds of matched payments
|
// For text search matching contact names — returns paymentHashes of matched payments
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT tcl.checkingId FROM tx_contact_links tcl
|
SELECT tcl.paymentHash FROM tx_contact_links tcl
|
||||||
INNER JOIN contacts c ON c.id = tcl.contactId
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
||||||
""")
|
""")
|
||||||
suspend fun findCheckingIdsByContactName(query: String): List<String>
|
suspend fun findPaymentHashesByContactName(query: String): List<String>
|
||||||
|
|
||||||
// For filter-by-contact — returns checkingIds for specific contact IDs
|
// For filter-by-contact — returns paymentHashs for specific contact IDs
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT DISTINCT checkingId FROM tx_contact_links
|
SELECT DISTINCT paymentHash FROM tx_contact_links
|
||||||
WHERE contactId IN (:contactIds)
|
WHERE contactId IN (:contactIds)
|
||||||
""")
|
""")
|
||||||
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String>
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
data class PaymentContactName(
|
data class PaymentContactName(
|
||||||
val checkingId : String,
|
val paymentHash : String,
|
||||||
val contactName : String
|
val contactName : String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,45 @@ interface PaymentDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(payment: PaymentRecordEntity)
|
suspend fun insert(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
// Safe upsert used by mergePayments only.
|
||||||
|
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
|
||||||
|
// the server-owned fields in-place — the parent row is never deleted,
|
||||||
|
// so the TxContactCrossRef CASCADE is never triggered.
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertIgnore(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_records SET
|
||||||
|
paymentHash = :paymentHash,
|
||||||
|
amountMsat = :amountMsat,
|
||||||
|
feeMsat = :feeMsat,
|
||||||
|
memo = :memo,
|
||||||
|
time = :time,
|
||||||
|
createdAt = :createdAt,
|
||||||
|
status = :status,
|
||||||
|
bolt11 = :bolt11,
|
||||||
|
pending = :pending,
|
||||||
|
preimage = :preimage,
|
||||||
|
extra = :extra,
|
||||||
|
savedAt = :savedAt
|
||||||
|
WHERE checkingId = :checkingId
|
||||||
|
""")
|
||||||
|
suspend fun updateSafe(
|
||||||
|
checkingId: String,
|
||||||
|
paymentHash: String,
|
||||||
|
amountMsat: Long,
|
||||||
|
feeMsat: Long,
|
||||||
|
memo: String?,
|
||||||
|
time: String,
|
||||||
|
createdAt: Long?,
|
||||||
|
status: String,
|
||||||
|
bolt11: String?,
|
||||||
|
pending: Boolean,
|
||||||
|
preimage: String?,
|
||||||
|
extra: String?,
|
||||||
|
savedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
/** Returns all rows ordered by time descending. */
|
/** Returns all rows ordered by time descending. */
|
||||||
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
||||||
suspend fun getAll(): List<PaymentRecordEntity>
|
suspend fun getAll(): List<PaymentRecordEntity>
|
||||||
@@ -38,7 +77,7 @@ interface PaymentDao {
|
|||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT * FROM payment_records
|
SELECT * FROM payment_records
|
||||||
WHERE checkingId IN (:checkingIds)
|
WHERE paymentHash IN (:paymentHashes)
|
||||||
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
|
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
|
||||||
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
|
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
|
||||||
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
|
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
|
||||||
@@ -47,8 +86,8 @@ interface PaymentDao {
|
|||||||
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
||||||
ORDER BY createdAt DESC, time DESC
|
ORDER BY createdAt DESC, time DESC
|
||||||
""")
|
""")
|
||||||
suspend fun getByCheckingIdsFiltered(
|
suspend fun getByPaymentHashesFiltered(
|
||||||
checkingIds : List<String>,
|
paymentHashes : List<String>,
|
||||||
minAmountMsat : Long? = null,
|
minAmountMsat : Long? = null,
|
||||||
maxAmountMsat : Long? = null,
|
maxAmountMsat : Long? = null,
|
||||||
minCreatedAt : Long? = null,
|
minCreatedAt : Long? = null,
|
||||||
|
|||||||
@@ -6,28 +6,10 @@ import androidx.room.Index
|
|||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "tx_contact_links",
|
tableName = "tx_contact_links",
|
||||||
primaryKeys = ["checkingId", "contactId"],
|
primaryKeys = ["paymentHash", "contactId"]
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = PaymentRecordEntity::class,
|
|
||||||
parentColumns = ["checkingId"],
|
|
||||||
childColumns = ["checkingId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
),
|
|
||||||
ForeignKey(
|
|
||||||
entity = ContactEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["contactId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [
|
|
||||||
Index("checkingId"),
|
|
||||||
Index("contactId")
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
data class TxContactCrossRef(
|
data class TxContactCrossRef(
|
||||||
val checkingId : String,
|
val paymentHash : String,
|
||||||
val contactId : String,
|
val contactId : String,
|
||||||
val role : String? = null,
|
val role : String? = null,
|
||||||
val createdAt : Long = System.currentTimeMillis()
|
val createdAt : Long = System.currentTimeMillis()
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ fun HistoryScreen(
|
|||||||
payment = payment,
|
payment = payment,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
contactName = contactNames[payment.checkingId],
|
contactName = contactNames[payment.paymentHash],
|
||||||
onClick = { onPaymentClick(payment) }
|
onClick = { onPaymentClick(payment) }
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
|
|||||||
@@ -133,13 +133,22 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults ->
|
val filteredState = combine(
|
||||||
|
syncManager.state,
|
||||||
|
_filter, _roomResults,
|
||||||
|
contactRepo.observeAllPaymentContactNames()
|
||||||
|
) { s, f, roomResults, contactNames ->
|
||||||
|
Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries")
|
||||||
if (s !is HistoryState.Success) return@combine s
|
if (s !is HistoryState.Success) return@combine s
|
||||||
if (roomResults == null) {
|
val payments = if (roomResults == null) {
|
||||||
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
s.payments.applyInMemoryFilters(f)
|
||||||
} else {
|
} else {
|
||||||
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
roomResults.applyInMemoryFilters(f)
|
||||||
}
|
}
|
||||||
|
s.copy(
|
||||||
|
payments = payments,
|
||||||
|
canLoadMore = roomResults == null && s.canLoadMore
|
||||||
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||||
|
|
||||||
// ── In-memory filter logic ────────────────────────────────────────────────
|
// ── In-memory filter logic ────────────────────────────────────────────────
|
||||||
@@ -164,7 +173,7 @@ class HistoryViewModel(
|
|||||||
contactRepo.observeAllPaymentContactNames()
|
contactRepo.observeAllPaymentContactNames()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.Eagerly,
|
||||||
initialValue = emptyMap()
|
initialValue = emptyMap()
|
||||||
)
|
)
|
||||||
// Maps contactId → display name (for filter chips)
|
// Maps contactId → display name (for filter chips)
|
||||||
@@ -203,6 +212,7 @@ class HistoryViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun assignContact(paymentHash: String, contactId: String) {
|
fun assignContact(paymentHash: String, contactId: String) {
|
||||||
|
Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
||||||
}
|
}
|
||||||
@@ -220,6 +230,7 @@ class HistoryViewModel(
|
|||||||
addressType : PaymentAddressType?,
|
addressType : PaymentAddressType?,
|
||||||
address : String?
|
address : String?
|
||||||
) {
|
) {
|
||||||
|
Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
@@ -228,6 +239,7 @@ class HistoryViewModel(
|
|||||||
lnAddress = lnAddress,
|
lnAddress = lnAddress,
|
||||||
lnUrl = lnUrl
|
lnUrl = lnUrl
|
||||||
)
|
)
|
||||||
|
Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash")
|
||||||
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,11 +277,11 @@ class HistoryViewModel(
|
|||||||
// ── Supplemental contact search ───────────────────────
|
// ── Supplemental contact search ───────────────────────
|
||||||
// 1. Text query may match contact names not in payment_records
|
// 1. Text query may match contact names not in payment_records
|
||||||
if (f.searchQuery.isNotBlank()) {
|
if (f.searchQuery.isNotBlank()) {
|
||||||
val contactCheckingIds = contactRepo
|
val contactPaymentHashes = contactRepo
|
||||||
.findCheckingIdsByContactName(f.searchQuery)
|
.findPaymentHashesByContactName(f.searchQuery)
|
||||||
if (contactCheckingIds.isNotEmpty()) {
|
if (contactPaymentHashes.isNotEmpty()) {
|
||||||
val contactPayments = paymentCache
|
val contactPayments = paymentCache
|
||||||
.queryByCheckingIds(contactCheckingIds, f)
|
.queryByPaymentHashes(contactPaymentHashes, f)
|
||||||
// Merge without duplicates
|
// Merge without duplicates
|
||||||
val existingIds = baseResults.map { it.checkingId }.toSet()
|
val existingIds = baseResults.map { it.checkingId }.toSet()
|
||||||
baseResults += contactPayments
|
baseResults += contactPayments
|
||||||
@@ -279,12 +291,12 @@ class HistoryViewModel(
|
|||||||
|
|
||||||
// 2. Explicit contact ID filter
|
// 2. Explicit contact ID filter
|
||||||
if (f.contactIds.isNotEmpty()) {
|
if (f.contactIds.isNotEmpty()) {
|
||||||
val contactCheckingIds = contactRepo
|
val contactPaymentHashes = contactRepo
|
||||||
.findCheckingIdsByContactIds(f.contactIds.toList())
|
.findPaymentHashesByContactIds(f.contactIds.toList())
|
||||||
if (contactCheckingIds.isNotEmpty()) {
|
if (contactPaymentHashes.isNotEmpty()) {
|
||||||
// Fetch ALL payments for these contacts, WITH the full filter applied
|
// Fetch ALL payments for these contacts, WITH the full filter applied
|
||||||
val contactFiltered = paymentCache.queryByCheckingIds(
|
val contactFiltered = paymentCache.queryByPaymentHashes(
|
||||||
checkingIds = contactCheckingIds,
|
paymentHashes = contactPaymentHashes,
|
||||||
filter = f
|
filter = f
|
||||||
)
|
)
|
||||||
_roomResults.value = contactFiltered
|
_roomResults.value = contactFiltered
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ class SendViewModel(
|
|||||||
// ── 3. Link payment → contact (FK now guaranteed to exist) ───────
|
// ── 3. Link payment → contact (FK now guaranteed to exist) ───────
|
||||||
runCatching {
|
runCatching {
|
||||||
contactRepo.linkPaymentToContact(
|
contactRepo.linkPaymentToContact(
|
||||||
checkingId = paymentHash,
|
paymentHash = paymentHash,
|
||||||
contactId = contactId,
|
contactId = contactId,
|
||||||
role = "recipient",
|
role = "recipient",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user