diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt index 4e10657..a3adad7 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -17,6 +17,11 @@ class ContactRepository(app: Application) { fun observeContact(id: String): Flow = dao.observeById(id) + /** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */ + fun observePaymentContactSummaries(): Flow> = + dao.observePaymentContactSummaries() + + // --- Create --- suspend fun createContact( @@ -189,4 +194,7 @@ class ContactRepository(app: Application) { suspend fun findPaymentHashesByContactIds(contactIds: List): List = dao.findPaymentHashesByContactIds(contactIds) + + suspend fun getContactsForPayment(paymentHash: String): List = + dao.getContactsForPayment(paymentHash) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt index 83d1c91..359387b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt @@ -1,5 +1,8 @@ package com.bitcointxoko.gudariwallet.data +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber private const val TAG = "FiatRateCache" @@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache" */ class FiatRateCache(private val fiatDataStore: FiatDataStore) { + private val _fiatFlow = MutableStateFlow>(Pair(null, null)) + + /** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */ + val fiatFlow: StateFlow> = _fiatFlow.asStateFlow() + + // ── Rate ────────────────────────────────────────────────────────────────── private var cachedRate : Double? = null @@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) { cachedRate = rate rateLastFetchedAt = now cachedRateCurrency = currency + _fiatFlow.value = Pair(currency, rate) fiatDataStore.persistRate(currency, rate) Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted") } @@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) { cachedRate = null rateLastFetchedAt = 0L cachedRateCurrency = "" + _fiatFlow.value = Pair(null, null) } // ── Currency list ───────────────────────────────────────────────────────── @@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) { Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies") } - val currentRate: Double? - get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() } + val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() } } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt index db625c8..4f1c030 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentCacheRepository.kt @@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter import com.bitcointxoko.gudariwallet.ui.history.StatusFilter +import kotlinx.coroutines.flow.Flow +import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity + private const val TAG = "PaymentCacheRepository" @@ -134,6 +137,9 @@ class PaymentCacheRepository(context: Context) { } } + /** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */ + fun observeAll(): Flow> = dao.observeAll() + // ── Payment detail ──────────────────────────────────────────────────────── /** Returns a cached detail response, or null if not yet fetched this session. */ diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentFeedRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentFeedRepository.kt new file mode 100644 index 0000000..7fedbc3 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentFeedRepository.kt @@ -0,0 +1,47 @@ +package com.bitcointxoko.gudariwallet.data + +import com.bitcointxoko.gudariwallet.data.db.PaymentContactSummary +import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity +import com.bitcointxoko.gudariwallet.data.model.ContactSummary +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem +import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem +import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class PaymentFeedRepository( + private val paymentCache : PaymentCacheRepository, + private val contactRepo : ContactRepository, + private val nodeAliasRepository : NodeAliasRepository, + private val fiatVm : FiatViewModel +) { + + fun observeFeedItems(): Flow> = combine( + paymentCache.observeAll(), + contactRepo.observePaymentContactSummaries(), + nodeAliasRepository.aliases, + fiatVm.fiatFlow + ) { payments: List, contactSummaries, aliasMap, fiatRate -> + val fiatCurrency = fiatRate.first + val fiatSatsPerUnit = fiatRate.second + + val contactsByPayment: Map> = + contactSummaries.groupBy { it.paymentHash } + + payments.map { entity -> + val linked: List = contactsByPayment[entity.paymentHash] + ?.map { ContactSummary(id = it.contactId, displayName = it.displayName) } + ?: emptyList() + + val resolvedAlias: String? = entity.pubkey?.let { aliasMap[it] } + + entity.toPaymentFeedItem( + linkedContacts = linked, + resolvedAlias = resolvedAlias, + fiatCurrency = fiatCurrency, + fiatSatsPerUnit = fiatSatsPerUnit + ) + } + }.distinctUntilChanged() +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt index 5be84a2..7aa7e38 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt @@ -12,6 +12,13 @@ data class ContactWithAddresses( val addresses: List ) +/** Flat projection used by PaymentFeedRepository to build ContactSummary lists. */ +data class PaymentContactSummary( + val paymentHash: String, + val contactId: String, + val displayName: String +) + @Dao interface ContactDao { @@ -125,6 +132,17 @@ interface ContactDao { """) fun observePaymentContactNames(): Flow> + // Returns all (paymentHash, contactId, displayName) triples — used by + // PaymentFeedRepository to build full ContactSummary lists per payment. + @Query(""" + SELECT tcl.paymentHash AS paymentHash, + c.id AS contactId, + COALESCE(c.localAlias, c.displayName, c.name) AS displayName + FROM tx_contact_links tcl + INNER JOIN contacts c ON c.id = tcl.contactId +""") + fun observePaymentContactSummaries(): Flow> + // For text search matching contact names — returns paymentHashes of matched payments @Query(""" SELECT tcl.paymentHash FROM tx_contact_links tcl @@ -139,4 +157,12 @@ interface ContactDao { WHERE contactId IN (:contactIds) """) suspend fun findPaymentHashesByContactIds(contactIds: List): List + + @Query(""" + SELECT c.* FROM contacts c + INNER JOIN tx_contact_links x ON c.id = x.contactId + WHERE x.paymentHash = :paymentHash +""") + suspend fun getContactsForPayment(paymentHash: String): List + } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt index 5822f45..8b92d79 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentDao.kt @@ -4,6 +4,8 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow + @Dao interface PaymentDao { @@ -56,6 +58,10 @@ interface PaymentDao { @Query("SELECT * FROM payment_records ORDER BY time DESC") suspend fun getAll(): List + /** Reactive version of [getAll] — emits a new list whenever any row changes. */ + @Query("SELECT * FROM payment_records ORDER BY COALESCE(createdAt, CAST(time AS INTEGER)) DESC") + fun observeAll(): Flow> + /** A single page, ordered newest-first. */ @Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset") suspend fun getPage(limit: Int, offset: Int): List diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/ContactSummary.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/ContactSummary.kt new file mode 100644 index 0000000..848e54a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/ContactSummary.kt @@ -0,0 +1,6 @@ +package com.bitcointxoko.gudariwallet.data.model + +data class ContactSummary( + val id: String, + val displayName: String // localAlias → displayName → name, resolved at mapping time +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt new file mode 100644 index 0000000..08ab518 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt @@ -0,0 +1,74 @@ +package com.bitcointxoko.gudariwallet.data.model + +import com.bitcointxoko.gudariwallet.api.PaymentRecord + +data class PaymentFeedItem( + + // ── Identity ───────────────────────────────────────────────────── + val id: String, // = paymentHash; stable LazyColumn key + + // ── Row (PaymentRow) ───────────────────────────────────────────── + val isOutgoing: Boolean, + val amountSat: Long, + val memo: String?, + val contactName: String?, // resolved: localAlias → displayName → name + val createdAt: Long?, + val time: Long?, + val isPending: Boolean, + val isFailed: Boolean, + val fiatCurrency: String?, + val fiatSatsPerUnit: Double?, + + // ── Filter / search keys ───────────────────────────────────────── + val paymentHash: String, // free-text search + technical detail + val tag: String?, // type filter (bolt11, keysend, etc.) + val contactIds: Set, // contact-ID filter + + // ── Detail (PaymentDetailScreen) — amounts ─────────────────────── + val feeSat: Long?, // shown if > 0 + + // ── Detail — contacts ──────────────────────────────────────────── + val linkedContacts: List, + + // ── Detail — basic info ────────────────────────────────────────── + val comment: String?, + + // ── Detail — LNURL success action ──────────────────────────────── + val successActionMessage: String?, + val successActionUrl: String?, + val successActionDescription: String?, + + // ── Detail — technical ─────────────────────────────────────────── + val preimage: String?, + val pubkey: String?, + val alias: String?, // live from NodeAliasCache, fallback to snapshot + val bolt11: String?, +) + +/** + * Reconstructs a minimal PaymentRecord from a PaymentFeedItem for use as + * a fallback in PaymentDetailScreen while the detail fetch is in flight. + * Fields not stored on PaymentFeedItem (checkingId, extra deserialized) are + * set to safe defaults — they will be replaced by the enriched record once + * DetailState.Success arrives. + */ +fun PaymentFeedItem.toBaseRecord(): PaymentRecord = PaymentRecord( + checkingId = paymentHash, // best approximation — checkingId not stored on feed item + paymentHash = paymentHash, + amountMsat = if (isOutgoing) -(amountSat * 1000L) else amountSat * 1000L, + feeMsat = (feeSat ?: 0L) * 1000L, + memo = memo, + time = time?.toString() ?: "", + createdAt = createdAt, + status = when { + isFailed -> "failed" + isPending -> "pending" + else -> "complete" + }, + bolt11 = bolt11, + pending = isPending, + preimage = preimage, + extra = null, // deserialized extra not stored on PaymentFeedItem + pubkey = pubkey, + alias = alias +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItemMapper.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItemMapper.kt new file mode 100644 index 0000000..ab04242 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItemMapper.kt @@ -0,0 +1,153 @@ +package com.bitcointxoko.gudariwallet.data.model + +import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.db.ContactEntity +import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +// Placeholder preimage that LNbits returns when the real one isn't available yet +private const val PREIMAGE_PLACEHOLDER = "0000000000000000000000000000000000000000000000000000000000000000" + +// Lightweight tag/comment extraction from raw extra JSON — +// avoids full PaymentExtra deserialization on every list emission. +// Falls back gracefully if the JSON is malformed or fields are absent. +private fun extractTag(extraJson: String?): String? { + if (extraJson == null) return null + return try { + Json.parseToJsonElement(extraJson).jsonObject["tag"]?.jsonPrimitive?.content + } catch (_: Exception) { null } +} + +private fun extractComment(extraJson: String?): String? { + if (extraJson == null) return null + return try { + Json.parseToJsonElement(extraJson).jsonObject["comment"]?.jsonPrimitive?.content + } catch (_: Exception) { null } +} + +// ── ContactEntity → ContactSummary ─────────────────────────────────────────── + +fun ContactEntity.toContactSummary(): ContactSummary = ContactSummary( + id = id, + displayName = localAlias + ?: displayName + ?: name + ?: id // last resort — never shown as empty +) + +// ── PaymentRecord → PaymentFeedItem ────────────────────────────────────────── + +// ── PaymentRecordEntity → PaymentFeedItem ───────────────────────────────────── +// Direct mapper — bypasses toDomain() to avoid PaymentRecord allocation +// and full PaymentExtra JSON deserialization on every feed emission. + +fun PaymentRecordEntity.toPaymentFeedItem( + linkedContacts : List, + resolvedAlias : String?, + fiatCurrency : String?, + fiatSatsPerUnit : Double? +): PaymentFeedItem { + val isOutgoing = amountMsat < 0 + val tag = extractTag(extra) + val comment = extractComment(extra) + + return PaymentFeedItem( + + // ── Identity ───────────────────────────────────────────────────────── + id = paymentHash, + + // ── Row ────────────────────────────────────────────────────────────── + isOutgoing = isOutgoing, + amountSat = Math.abs(amountMsat) / 1000L, + memo = memo, + contactName = linkedContacts.firstOrNull()?.displayName, + createdAt = createdAt, + time = time.toLongOrNull(), + isPending = pending, + isFailed = status == "failed", + fiatCurrency = fiatCurrency, + fiatSatsPerUnit = fiatSatsPerUnit, + + // ── Filter / search keys ───────────────────────────────────────────── + paymentHash = paymentHash, + tag = tag, + contactIds = linkedContacts.map { it.id }.toSet(), + + // ── Detail — amounts ───────────────────────────────────────────────── + feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null, + + // ── Detail — contacts ──────────────────────────────────────────────── + linkedContacts = linkedContacts, + + // ── Detail — basic info ────────────────────────────────────────────── + comment = comment, + + // ── Detail — LNURL success action ──────────────────────────────────── + // Not extracted in the list mapper — PaymentDetailDelegate loads full + // extra via toDomain() when the detail screen is opened. + successActionMessage = null, + successActionUrl = null, + successActionDescription = null, + + // ── Detail — technical ─────────────────────────────────────────────── + preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER }, + pubkey = pubkey, + alias = resolvedAlias ?: alias, + bolt11 = bolt11, + ) +} + +fun PaymentRecord.toPaymentFeedItem( + linkedContacts: List, + resolvedAlias: String?, // from NodeAliasCache; fallback to payment.alias handled here + fiatCurrency: String?, + fiatSatsPerUnit: Double? +): PaymentFeedItem { + + val isOutgoing = amountMsat < 0 + + return PaymentFeedItem( + + // ── Identity ───────────────────────────────────────────────────────── + id = paymentHash, + + // ── Row ────────────────────────────────────────────────────────────── + isOutgoing = isOutgoing, + amountSat = Math.abs(amountMsat) / 1000L, + memo = memo, + contactName = linkedContacts.firstOrNull()?.displayName, + createdAt = createdAt, + time = time.toLongOrNull(), + isPending = pending, + isFailed = status == "failed", + fiatCurrency = fiatCurrency, + fiatSatsPerUnit = fiatSatsPerUnit, + + // ── Filter / search keys ───────────────────────────────────────────── + paymentHash = paymentHash, + tag = extra?.tag, + contactIds = linkedContacts.map { it.id }.toSet(), + + // ── Detail — amounts ───────────────────────────────────────────────── + feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null, + + // ── Detail — contacts ──────────────────────────────────────────────── + linkedContacts = linkedContacts, + + // ── Detail — basic info ────────────────────────────────────────────── + comment = extra?.comment, + + // ── Detail — LNURL success action ──────────────────────────────────── + successActionMessage = extra?.successAction?.message, + successActionUrl = extra?.successAction?.url, + successActionDescription = extra?.successAction?.description, + + // ── Detail — technical ─────────────────────────────────────────────── + preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER }, + pubkey = pubkey, + alias = resolvedAlias ?: alias, // live cache first, snapshot fallback + bolt11 = bolt11, + ) +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt index e9932c0..24529c2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/UiStates.kt @@ -23,13 +23,13 @@ sealed class ReceiveState { val amountSats : Long, val memo : String, val expiresAt : Long - ) : ReceiveState() - data class PaymentReceived( + ) : ReceiveState() + data class PaymentReceived( val amountSats: Long, val memo : String?, - val checkingId : String - ) : ReceiveState() - data class Error(val message: String) : ReceiveState() + val paymentHash : String + ) : ReceiveState() + data class Error(val message: String) : ReceiveState() data class LnurlWithdrawReady( val k1 : String, val callback : String, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index d55f620..d900e12 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -1,6 +1,8 @@ package com.bitcointxoko.gudariwallet.ui import android.content.ClipboardManager +import android.os.Build +import androidx.annotation.RequiresApi import timber.log.Timber import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -72,7 +74,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.util.SendInputType -import com.bitcointxoko.gudariwallet.data.ContactRepository +import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel import kotlinx.coroutines.delay @@ -90,13 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) { private const val ROUTE_HOME = "home" private const val ROUTE_SCANNER = "scanner" private const val ROUTE_HISTORY = "history" -private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" +private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}" private const val ROUTE_NWC = "nwc" private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}" private const val ROUTE_CONTACTS = "contacts" private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}" +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @Composable fun WalletScreen( vm : WalletViewModel, @@ -131,10 +134,17 @@ fun WalletScreen( aliasRepo = vm.aliasRepo, paymentCache = vm.paymentCache, contactRepo = vm.contactRepo, + feedRepository = PaymentFeedRepository( + paymentCache = vm.paymentCache, + contactRepo = vm.contactRepo, + nodeAliasRepository = vm.aliasRepo, + fiatVm = vm.fiatVm + ), fiatCurrency = vm.selectedCurrency, fiatSatsPerUnit = vm.fiatSatsPerUnit ) } + val historyVm: HistoryViewModel = viewModel(factory = historyFactory) val nwcVm: NwcViewModel = viewModel( factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm @@ -367,18 +377,18 @@ fun WalletScreen( ReceiveScreen( vm = vm, nfcVm = nfcVm, - onNavigateToPaymentDetail = { checkingId -> - navController.navigate("payment_detail/$checkingId") + onNavigateToPaymentDetail = { paymentHash -> + navController.navigate("payment_detail/$paymentHash") } ) } composable(TabItem.Send.route) { SendScreen( - vm = vm, + vm = vm, nfcVm = nfcVm, - onViewDetails = { checkingId, record -> + onViewDetails = { paymentHash, record -> historyVm.primePayment(record) - navController.navigate("payment_detail/$checkingId") { + navController.navigate("payment_detail/$paymentHash") { launchSingleTop = true } }, @@ -422,34 +432,30 @@ fun WalletScreen( onClose = { navController.popBackStack(ROUTE_HOME, inclusive = false) }, - onPaymentClick = { payment -> - navController.navigate("payment_detail/${payment.checkingId}") + onPaymentClick = { item -> + navController.navigate("payment_detail/${item.paymentHash}") } ) } // Payment detail — back → history (natural back stack) composable( - route = ROUTE_PAYMENT_DETAIL, - arguments = listOf(navArgument("checkingId") { type = NavType.StringType }) + route = ROUTE_PAYMENT_DETAIL, + arguments = listOf(navArgument("paymentHash") { type = NavType.StringType }) ) { backStackEntry -> - val checkingId = backStackEntry.arguments?.getString("checkingId") ?: "" - val historyState by historyVm.filteredState.collectAsStateWithLifecycle() - val fallbackState by historyVm.state.collectAsStateWithLifecycle() - val payment = remember(historyState, fallbackState, checkingId) { - (historyState as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } - ?: (fallbackState as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } + val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: "" + val feedItems by historyVm.feedItems.collectAsStateWithLifecycle() + val item = remember(feedItems, paymentHash) { + historyVm.findPayment(paymentHash) } - if (payment != null) { + if (item != null) { PaymentDetailScreen( - payment = payment, - vm = historyVm, - onBack = { navController.popBackStack() } + item = item, + vm = historyVm, + onBack = { navController.popBackStack() } ) } else { - LaunchedEffect(checkingId) { historyVm.refresh() } + LaunchedEffect(paymentHash) { historyVm.refresh() } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt index f399ce1..2df239d 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt @@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -95,6 +96,17 @@ class FiatViewModel( initialValue = null ) + /** Combined fiat stream for [PaymentFeedRepository] feed assembly. */ + val fiatFlow: StateFlow> = combine( + selectedCurrency, + fiatSatsPerUnit + ) { currency, rate -> Pair(currency, rate) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Pair(null, null) + ) + // ── Called by BalanceViewModel after every successful balance refresh ───── fun onBalanceRefreshed() { viewModelScope.launch { fetchAndApplyFiatRate() } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt index c1fe3d7..aa3f8b4 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryScreen.kt @@ -56,46 +56,47 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp -import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.LocalAppStrings +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.util.formatEpochShort import kotlinx.coroutines.launch -// ── List screen ────────────────────────────────────────────────────────────── +// ── List screen ─────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable fun HistoryScreen( vm : HistoryViewModel, onClose : () -> Unit, - onPaymentClick : (PaymentRecord) -> Unit + onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem ) { - val strings = LocalAppStrings.current - val state by vm.filteredState.collectAsState() - val filter by vm.filter.collectAsState() - val fiatCurrency by vm.fiatCurrency.collectAsState() - val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() - val availableTypes by vm.availableTypes.collectAsState() + val strings = LocalAppStrings.current + val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell) + val items by vm.feedItems.collectAsState() // ← filtered List + val filter by vm.filter.collectAsState() + val availableTypes by vm.availableTypes.collectAsState() val listState = rememberLazyListState() - val contactNames by vm.contactNames.collectAsState() - var filterSheetVisible by rememberSaveable { mutableStateOf(false) } - val dismissFilterSheet = { filterSheetVisible = false } - var searchVisible by rememberSaveable { mutableStateOf(false) } + // fiatCurrency, fiatSatsPerUnit, contactNames — all removed: + // they are now embedded in each PaymentFeedItem + + var filterSheetVisible by rememberSaveable { mutableStateOf(false) } + val dismissFilterSheet = { filterSheetVisible = false } + var searchVisible by rememberSaveable { mutableStateOf(false) } var searchInfoSheetVisible by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isFiltered = filter.isActive Scaffold( topBar = { TopAppBar( - title = { Text(strings.history.historyTitle) }, // ← "History" + title = { Text(strings.history.historyTitle) }, navigationIcon = { IconButton(onClick = onClose) { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.historyClose // ← "Close" + contentDescription = strings.history.historyClose ) } }, @@ -109,7 +110,7 @@ fun HistoryScreen( }) { Icon( imageVector = Icons.Default.Search, - contentDescription = strings.history.historySearchPayments // ← "Search payments" + contentDescription = strings.history.historySearchPayments ) } } @@ -119,7 +120,7 @@ fun HistoryScreen( IconButton(onClick = { filterSheetVisible = true }) { Icon( imageVector = Icons.Default.FilterList, - contentDescription = strings.history.historyFilterPayments // ← "Filter payments" + contentDescription = strings.history.historyFilterPayments ) } } @@ -140,7 +141,7 @@ fun HistoryScreen( .fillMaxSize() .padding(padding) ) { - // ── Search bar ─────────────────────────────────────────────────── + // ── Search bar ───────────────────────────────────────────────── if (searchVisible) { SearchBar( query = filter.searchQuery, @@ -154,7 +155,7 @@ fun HistoryScreen( HorizontalDivider() } - // ── Active filter chips ────────────────────────────────────────── + // ── Active filter chips ──────────────────────────────────────── val searchActive = filter.searchQuery.isNotBlank() if (isFiltered || searchActive) { ActiveFilterChips( @@ -172,14 +173,84 @@ fun HistoryScreen( ) } - // ── Content ────────────────────────────────────────────────────── + // ── Content ──────────────────────────────────────────────────── Box(modifier = Modifier.fillMaxSize()) { - when (val s = state) { - is HistoryState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + // ── List — always rendered when state is Success, never unmounted ────── + val s = state + if (s is HistoryState.Success) { + val shouldLoadMore by remember { + derivedStateOf { + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull() + val totalItems = listState.layoutInfo.totalItemsCount + lastVisible != null && lastVisible.index >= totalItems - 8 + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore && s.canLoadMore) vm.loadMore() } + if (items.isEmpty()) { + Text( + text = strings.history.historyNoPayments, + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + PullToRefreshBox( + isRefreshing = s.isRefreshing, + onRefresh = { vm.refresh() } + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = items, + key = { it.paymentHash }, + contentType = { "payment" } + ) { item -> + PaymentRow( + item = item, + onClick = { onPaymentClick(item) } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp + ) + } + if (s.canLoadMore) { + item(contentType = "loader") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + } + } + } + } + } + + // ── State overlays — sit on top of the list, never destroy it ───────── + when (val s = state) { + is HistoryState.Loading -> { + // Only show the full-screen spinner on the very first load + // (when we have no items yet). During loadMore(), the in-list + // footer spinner handles it — don't obscure the results. + if (items.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } is HistoryState.Error -> { Column( modifier = Modifier.align(Alignment.Center), @@ -192,96 +263,33 @@ fun HistoryScreen( ) Spacer(Modifier.height(12.dp)) Button(onClick = { vm.refresh() }) { - Text(strings.history.historyRetry) // ← "Retry" - } - } - } - - is HistoryState.Success -> { - val shouldLoadMore by remember { - derivedStateOf { - val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull() - val totalItems = listState.layoutInfo.totalItemsCount - lastVisible != null && lastVisible.index >= totalItems - 8 - } - } - LaunchedEffect(shouldLoadMore) { - if (shouldLoadMore && s.canLoadMore) vm.loadMore() - } - - if (s.payments.isEmpty()) { - Text( - text = strings.history.historyNoPayments, // ← "No payments yet." - modifier = Modifier.align(Alignment.Center), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - PullToRefreshBox( - isRefreshing = s.isRefreshing, - onRefresh = { vm.refresh() } - ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - items( - items = s.payments, - key = { it.paymentHash }, - contentType = { "payment" } - ) { payment -> - PaymentRow( - payment = payment, - fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit, - contactName = contactNames[payment.paymentHash], - onClick = { onPaymentClick(payment) } - ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 0.5.dp - ) - } - if (s.canLoadMore) { - item(contentType = "loader") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp - ) - } - } - } - } + Text(strings.history.historyRetry) } } } + is HistoryState.Success -> { /* handled above */ } } } + } } - // ── Sheets (outside Scaffold so they overlay correctly) ────────────────── + + // ── Sheets (outside Scaffold so they overlay correctly) ─────────────────── if (filterSheetVisible) { FilterBottomSheet( - currentFilter = filter, - availableTypes = availableTypes, - sheetState = sheetState, - onDismiss = dismissFilterSheet, - onDirectionSelected = { direction -> + currentFilter = filter, + availableTypes = availableTypes, + sheetState = sheetState, + onDismiss = dismissFilterSheet, + onDirectionSelected = { direction -> vm.setDirectionFilter(direction) dismissFilterSheet() }, - onStatusToggled = { vm.toggleStatusFilter(it) }, - onTypeToggled = { vm.toggleTypeFilter(it) }, - onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }, + onStatusToggled = { vm.toggleStatusFilter(it) }, + onTypeToggled = { vm.toggleTypeFilter(it) }, + onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }, onDatePresetSelected = { vm.applyDatePreset(it) }, - onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) }, + onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) }, onDateClearRequested = { vm.clearDateFilter() } ) } @@ -291,7 +299,7 @@ fun HistoryScreen( ) } -// ── Search bar ─────────────────────────────────────────────────────────────── +// ── Search bar ──────────────────────────────────────────────────────────────── @Composable private fun SearchBar( query : String, @@ -312,13 +320,13 @@ private fun SearchBar( IconButton(onClick = onInfoClick) { Icon( imageVector = Icons.Default.Info, - contentDescription = strings.history.historySearchHelp // ← "Search help" + contentDescription = strings.history.historySearchHelp ) } TextField( value = query, onValueChange = onQueryChange, - placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions" + placeholder = { Text(strings.history.historySearchPlaceholder) }, singleLine = true, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, @@ -339,7 +347,7 @@ private fun SearchBar( }) { Icon( imageVector = Icons.Default.ContentPaste, - contentDescription = strings.history.historySearchPaste // ← "Paste" + contentDescription = strings.history.historySearchPaste ) } IconButton( @@ -348,7 +356,7 @@ private fun SearchBar( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.historySearchClear, // ← "Clear search" + contentDescription = strings.history.historySearchClear, tint = if (query.isNotEmpty()) LocalContentColor.current else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) @@ -356,22 +364,22 @@ private fun SearchBar( } } -// ── Active filter chips ────────────────────────────────────────────────────── +// ── Active filter chips ─────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ActiveFilterChips( - filter : PaymentFilter, - searchActive : Boolean, - onClearDirection: () -> Unit, - onClearStatus : (StatusFilter) -> Unit, - onClearType : (String) -> Unit, - onClearAmount : () -> Unit, - onClearDate : () -> Unit, - onClearSearch : () -> Unit + filter : PaymentFilter, + searchActive : Boolean, + onClearDirection : () -> Unit, + onClearStatus : (StatusFilter) -> Unit, + onClearType : (String) -> Unit, + onClearAmount : () -> Unit, + onClearDate : () -> Unit, + onClearSearch : () -> Unit ) { val strings = LocalAppStrings.current FlowRow( - modifier = Modifier + modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -384,15 +392,15 @@ private fun ActiveFilterChips( label = { Text( when (filter.direction) { - DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing" - else -> strings.history.filterDirectionIncoming // ← "Incoming" + DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing + else -> strings.history.filterDirectionIncoming } ) }, trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter" + contentDescription = strings.history.filterClearDirection, modifier = Modifier.size(16.dp) ) } @@ -405,16 +413,16 @@ private fun ActiveFilterChips( label = { Text( when (s) { - StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed" - StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending" - StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed" + StatusFilter.COMPLETED -> strings.history.filterStatusCompleted + StatusFilter.PENDING -> strings.history.filterStatusPending + StatusFilter.FAILED -> strings.history.filterStatusFailed } ) }, trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter" + contentDescription = strings.history.filterRemoveStatus, modifier = Modifier.size(16.dp) ) } @@ -428,7 +436,7 @@ private fun ActiveFilterChips( trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.filterRemoveType, // ← "Remove type filter" + contentDescription = strings.history.filterRemoveType, modifier = Modifier.size(16.dp) ) } @@ -437,11 +445,11 @@ private fun ActiveFilterChips( if (filter.minAmountSat != null || filter.maxAmountSat != null) { val label = when { filter.minAmountSat != null && filter.maxAmountSat != null -> - strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats" + strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) filter.minAmountSat != null -> - strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats" + strings.history.filterAmountMin(filter.minAmountSat) else -> - strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats" + strings.history.filterAmountMax(filter.maxAmountSat!!) } InputChip( selected = true, @@ -450,7 +458,7 @@ private fun ActiveFilterChips( trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter" + contentDescription = strings.history.filterClearAmount, modifier = Modifier.size(16.dp) ) } @@ -460,19 +468,23 @@ private fun ActiveFilterChips( val maxCreatedAt = filter.maxCreatedAt if (minCreatedAt != null || maxCreatedAt != null) { val label = when (filter.datePreset) { - DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week" - DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month" - DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year" + DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek + DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth + DatePreset.THIS_YEAR -> strings.history.filterDateThisYear null -> when { minCreatedAt != null && maxCreatedAt != null -> - strings.history.filterDateRange( // ← "X–Y" + strings.history.filterDateRange( formatEpochShort(minCreatedAt, strings.today, strings.yesterday), formatEpochShort(maxCreatedAt, strings.today, strings.yesterday) ) minCreatedAt != null -> - strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X" + strings.history.filterDateFrom( + formatEpochShort(minCreatedAt, strings.today, strings.yesterday) + ) else -> - strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X" + strings.history.filterDateUntil( + formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday) + ) } } InputChip( @@ -482,7 +494,7 @@ private fun ActiveFilterChips( trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.filterClearDate, // ← "Clear date filter" + contentDescription = strings.history.filterClearDate, modifier = Modifier.size(16.dp) ) } @@ -492,11 +504,11 @@ private fun ActiveFilterChips( InputChip( selected = true, onClick = onClearSearch, - label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX" + label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = strings.history.historyClearSearch, // ← "Clear search" + contentDescription = strings.history.historyClearSearch, modifier = Modifier.size(16.dp) ) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt index 446c12a..eeaf58b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt @@ -8,175 +8,265 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.data.ContactRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository +import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.db.ContactEntity import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType +import com.bitcointxoko.gudariwallet.data.model.ContactSummary +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem +import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.HistoryState +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds - private const val TAG = "HistoryViewModel" @OptIn(FlowPreview::class) class HistoryViewModel( - private val repo : WalletRepository, - private val aliasRepo : NodeAliasRepository, - private val paymentCache : PaymentCacheRepository, - private val contactRepo : ContactRepository, - val fiatCurrency : StateFlow, - val fiatSatsPerUnit : StateFlow + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val paymentCache : PaymentCacheRepository, + private val contactRepo : ContactRepository, + private val feedRepository : PaymentFeedRepository, + val fiatCurrency : StateFlow, + val fiatSatsPerUnit : StateFlow ) : ViewModel() { - // ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ───────── + + // ── Sync manager ────────────────────────────────────────────────────────── + // Owns the websocket/sync loop, pagination, and optimistic inserts. + // Still operates on PaymentRecord internally — untouched by this refactor. private val syncManager: PaymentSyncManager = PaymentSyncManager( - repo = repo, - paymentCache = paymentCache, - scope = viewModelScope, + repo = repo, + paymentCache = paymentCache, + scope = viewModelScope, onNewPayments = { payments -> enricher.enrich(payments) } ) + // ── Optimistic insert (called after a successful send) ───────────────────── fun primePayment(record: PaymentRecord) { syncManager.primePayment(record) } + + // ── Enricher ────────────────────────────────────────────────────────────── + // Resolves pubkey/alias post-send and writes back into NodeAliasCache, + // which feeds into the feedRepository.observeFeedItems() combine. private val enricher: PaymentEnricher = PaymentEnricher( - repo = repo, - aliasRepo = aliasRepo, + repo = repo, + aliasRepo = aliasRepo, paymentCache = paymentCache, - scope = viewModelScope, - onEnriched = { paymentHash, pubkey, alias -> + scope = viewModelScope, + onEnriched = { paymentHash, pubkey, alias -> syncManager.updateEnrichedPayment(paymentHash, pubkey, alias) } ) + + // ── Sync status ─────────────────────────────────────────────────────────── + // Exposes Loading / Error / Success shell (isRefreshing, canLoadMore). + // Does NOT carry payment data — see feedItems below. val state: StateFlow = syncManager.state - fun refresh() = syncManager.refresh() + + fun refresh() = syncManager.refresh() fun loadMore() = syncManager.loadMore( - filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true } + filteredCanLoadMore = { + (state.value as? HistoryState.Success)?.canLoadMore == true + } ) - // ── Detail delegate ─────────────────────────────────────────────────────── + // ── Detail delegate ──────────────────────────────────────────────────────── private val detailDelegate = PaymentDetailDelegate( - repo = repo, + repo = repo, + aliasRepo = aliasRepo, paymentCache = paymentCache, - scope = viewModelScope + scope = viewModelScope ) val detailState: StateFlow = detailDelegate.state - fun loadDetail(checkingId: String, record: PaymentRecord? = null) = - detailDelegate.load(checkingId, record) + fun loadDetail(paymentHash: String, record: PaymentRecord? = null) = + detailDelegate.load(paymentHash, record) + fun clearDetail() = detailDelegate.clear() - // ── Filter state ────────────────────────────────────────────────────────── + // ── Filter state ─────────────────────────────────────────────────────────── private val _filter = MutableStateFlow(PaymentFilter()) val filter: StateFlow = _filter - fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } } - fun clearSearch() { _filter.update { it.copy(searchQuery = "") } } - - fun setDirectionFilter(direction: DirectionFilter) { - _filter.update { it.copy(direction = direction) } + fun setSearchQuery(q: String) { + _filter.update { it.copy(searchQuery = q.trim()) } } - fun toggleStatusFilter(status: StatusFilter) { + + fun clearSearch() { + _filter.update { it.copy(searchQuery = "") } + } + + fun setDirectionFilter(d: DirectionFilter) { + _filter.update { it.copy(direction = d) } + } + + fun toggleStatusFilter(s: StatusFilter) { _filter.update { f -> - val updated = if (status in f.statuses) f.statuses - status else f.statuses + status + val updated = if (s in f.statuses) f.statuses - s else f.statuses + s f.copy(statuses = updated) } } + fun toggleTypeFilter(type: String) { _filter.update { f -> val updated = if (type in f.types) f.types - type else f.types + type f.copy(types = updated) } } + fun setAmountFilter(min: Long?, max: Long?) { _filter.update { it.copy(minAmountSat = min, maxAmountSat = max) } } + fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) { _filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) } } + fun applyDatePreset(preset: DatePreset) { - val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt + val (min, max) = preset.toEpochSecondRange() setDateFilter(min, max, preset) } + fun clearDateFilter() { _filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) } } - fun findPayment(checkingId: String): PaymentRecord? { - val fromFiltered = (filteredState.value as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } - if (fromFiltered != null) return fromFiltered - return (state.value as? HistoryState.Success) - ?.payments?.firstOrNull { it.checkingId == checkingId } + fun toggleContactFilter(contactId: String) { + _filter.update { f -> + val updated = if (contactId in f.contactIds) + f.contactIds - contactId else f.contactIds + contactId + f.copy(contactIds = updated) + } } + fun clearContactFilter() { + _filter.update { it.copy(contactIds = emptySet()) } + } // ── Room-backed query results ────────────────────────────────────────────── - private val _roomResults = MutableStateFlow?>(null) + // Null = use the live feed stream. + // Non-null = a search / contact-ID Room query has run and its results + // override the live stream until the filter is cleared. + private val _roomResults = MutableStateFlow?>(null) - // ── Derived state ───────────────────────────────────────────────────────── - - /** Distinct non-null tags present in the currently loaded list. */ - val availableTypes: StateFlow> = syncManager.state - .map { s -> - if (s !is HistoryState.Success) emptyList() - else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted() + // ── Feed items — single source of truth for the UI list ─────────────────── + // Emits a freshly filtered List whenever any upstream + // source changes: payments DB, contact links, alias cache, fiat rate, + // active filter, or Room query override. + val feedItems: StateFlow> = combine( + feedRepository.observeFeedItems(), + _filter, + _roomResults + ) { feed, f, roomResults -> + Triple(feed, f, roomResults) + } + .distinctUntilChanged { old, new -> + // If room results are active on both sides and haven't changed, suppress + val oldRoom = old.third + val newRoom = new.third + if (oldRoom != null && newRoom != null && oldRoom === newRoom && old.second == new.second) { + return@distinctUntilChanged true // same room results, same filter — skip + } + false // otherwise let through } + .map { (feed, f, roomResults) -> + withContext(Dispatchers.Default) { + roomResults?.applyFeedFilters( + f.copy(searchQuery = "", contactIds = emptySet()) + ) ?: feed.applyFeedFilters(f) + } + } + .distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - 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 - val payments = if (roomResults == null) { - s.payments.applyInMemoryFilters(f) - } else { - roomResults.applyInMemoryFilters(f) - } - s.copy( - payments = payments, - canLoadMore = roomResults == null && s.canLoadMore - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) + // ── Available type filter chips ──────────────────────────────────────────── + val availableTypes: StateFlow> = feedItems + .map { items -> items.mapNotNull { it.tag }.distinct().sorted() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - // ── In-memory filter logic ──────────────────────────────────────────────── - private fun List.applyInMemoryFilters(f: PaymentFilter) = + // ── Find a single item by paymentHash ───────────────────────────────────── + // paymentHash supersedes checkingId as the stable unique identifier. + // Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip. + fun findPayment(paymentHash: String): PaymentFeedItem? = + feedItems.value.firstOrNull { it.paymentHash == paymentHash } + + // ── In-memory filter logic ───────────────────────────────────────────────── + // All filter keys map directly to PaymentFeedItem fields — no extra + // resolution needed (contact names, alias, fiat are already embedded). + private fun List.applyInMemoryFilters(f: PaymentFilter): List = distinctBy { it.paymentHash } .let { list -> when (f.direction) { - DirectionFilter.ALL -> list + DirectionFilter.ALL -> list DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } } } + .let { list -> + if (f.statuses.isEmpty()) list + else list.filter { item -> + f.statuses.any { status -> + when (status) { + StatusFilter.PENDING -> item.isPending + StatusFilter.FAILED -> item.isFailed + StatusFilter.COMPLETED -> !item.isPending && !item.isFailed + } + } + } + } .let { list -> if (f.types.isEmpty()) list - else list.filter { it.extra?.tag in f.types } + else list.filter { it.tag in f.types } + } + .let { list -> + val min = f.minAmountSat; + val max = f.maxAmountSat + if (min == null && max == null) list + else list.filter { item -> + (min == null || item.amountSat >= min) && + (max == null || item.amountSat <= max) + } + } + .let { list -> + val min = f.minCreatedAt; + val max = f.maxCreatedAt + if (min == null && max == null) list + else list.filter { item -> + val ts = item.createdAt ?: item.time + ts != null && + (min == null || ts >= min) && + (max == null || ts <= max) + } + } + .let { list -> + val q = f.searchQuery + if (q.isBlank()) list + else list.filter { item -> + item.memo?.contains(q, ignoreCase = true) == true || + item.contactName?.contains(q, ignoreCase = true) == true || + item.paymentHash.contains(q, ignoreCase = true) + } } - // ── Contact name map ───────────────────────────────────────────────────── - // Maps checkingId → display name for any payment that has a linked contact. - // Rebuilt whenever the underlying tx_contact_links or contacts tables change. - val contactNames: StateFlow> = - contactRepo.observeAllPaymentContactNames() - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = emptyMap() - ) - // Maps contactId → display name (for filter chips) + // ── Contact display names (for filter chips) ─────────────────────────────── + // Used to render contact names in active filter chips on the UI. + // Payment row contact names are embedded in PaymentFeedItem.contactName. val contactDisplayNames: StateFlow> = contactRepo.observeAllContacts() .map { contacts -> @@ -193,8 +283,7 @@ class HistoryViewModel( initialValue = emptyMap() ) - // ── Contact assignment ─────────────────────────────────────────────────── - + // ── Per-payment contact queries ──────────────────────────────────────────── fun contactsForPayment(paymentHash: String): StateFlow> = contactRepo.observeContactsForPayment(paymentHash) .stateIn( @@ -211,140 +300,169 @@ class HistoryViewModel( initialValue = emptyList() ) + // ── Contact assignment ───────────────────────────────────────────────────── fun assignContact(paymentHash: String, contactId: String) { - Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId") - viewModelScope.launch { - contactRepo.linkPaymentToContact(paymentHash, contactId) - } + Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId") + viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) } } fun unassignContact(paymentHash: String, contactId: String) { - viewModelScope.launch { - contactRepo.unlinkPaymentFromContact(paymentHash, contactId) - } + viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) } } fun saveContactAndAssign( - paymentHash : String, - name : String, - addressType : PaymentAddressType?, - address : String? + paymentHash: String, + name: String, + addressType: PaymentAddressType?, + address: String? ) { - Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name") + Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name") viewModelScope.launch { val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS } - val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } + val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } val contact = contactRepo.createContact( localAlias = name, - lnAddress = lnAddress, - lnUrl = lnUrl + lnAddress = lnAddress, + lnUrl = lnUrl ) - Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash") + Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking") contactRepo.linkPaymentToContact(paymentHash, contact.id) } } - fun toggleContactFilter(contactId: String) { - _filter.update { f -> - val updated = if (contactId in f.contactIds) - f.contactIds - contactId else f.contactIds + contactId - f.copy(contactIds = updated) - } - } - fun clearContactFilter() { - _filter.update { it.copy(contactIds = emptySet()) } - } - - - // ── Init ────────────────────────────────────────────────────────────────── + // ── Init ─────────────────────────────────────────────────────────────────── init { syncManager.init() + // ── Room query path ──────────────────────────────────────────────────── + // Activated when the filter requires DB-level text search or contact-ID + // lookup. Results are stored in _roomResults and override the live feed + // stream in feedItems. Cleared back to null when no Room query is needed. viewModelScope.launch { combine( _filter.map { it.searchQuery }.debounce(300.milliseconds), _filter ) { _, f -> f } + .distinctUntilChanged() .collect { f -> if (!f.needsRoomQuery()) { - Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") + Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed") _roomResults.value = null - } else { - // ── Room query path ─────────────────────────────────── - val baseResults = paymentCache.queryAll(f.searchQuery, f) - .toMutableList() + return@collect + } - // ── Supplemental contact search ─────────────────────── - // 1. Text query may match contact names not in payment_records - if (f.searchQuery.isNotBlank()) { - val contactPaymentHashes = contactRepo - .findPaymentHashesByContactName(f.searchQuery) - if (contactPaymentHashes.isNotEmpty()) { - val contactPayments = paymentCache - .queryByPaymentHashes(contactPaymentHashes, f) - // Merge without duplicates - val existingIds = baseResults.map { it.checkingId }.toSet() - baseResults += contactPayments - .filter { it.checkingId !in existingIds } - } + // Snapshot the contact summaries once per query — no new DAO needed. + // Groups PaymentContactSummary by paymentHash so toFeedItem() can + // resolve contact names without per-row DB calls. + val contactSnapshot: Map> = + contactRepo.observePaymentContactSummaries() + .first() + .groupBy( + keySelector = { it.paymentHash }, + valueTransform = { + ContactSummary( + id = it.contactId, + displayName = it.displayName + ) + } + ) + + // ── Explicit contact-ID filter (highest priority) ────────── + if (f.contactIds.isNotEmpty()) { + val contactHashes = contactRepo + .findPaymentHashesByContactIds(f.contactIds.toList()) + _roomResults.value = if (contactHashes.isNotEmpty()) { + paymentCache.queryByPaymentHashes(contactHashes, f) + .map { it.toFeedItem(contactSnapshot) } + .also { + Timber.tag(TAG).d( + "FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results" + ) + } + } else { + emptyList() } + return@collect + } - // 2. Explicit contact ID filter - if (f.contactIds.isNotEmpty()) { - val contactPaymentHashes = contactRepo - .findPaymentHashesByContactIds(f.contactIds.toList()) - if (contactPaymentHashes.isNotEmpty()) { - // Fetch ALL payments for these contacts, WITH the full filter applied - val contactFiltered = paymentCache.queryByPaymentHashes( - paymentHashes = contactPaymentHashes, - filter = f - ) - _roomResults.value = contactFiltered - .also { - Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results") - } - return@collect - } else { - // Selected contacts have no payments — return empty - _roomResults.value = emptyList() - return@collect - } - } + // ── Text search ──────────────────────────────────────────── + val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList() - _roomResults.value = baseResults.also { - Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results") + if (f.searchQuery.isNotBlank()) { + val contactHashes = contactRepo + .findPaymentHashesByContactName(f.searchQuery) + if (contactHashes.isNotEmpty()) { + val existingIds = baseRecords.map { it.checkingId }.toSet() + baseRecords += paymentCache + .queryByPaymentHashes(contactHashes, f) + .filter { it.checkingId !in existingIds } } } + + _roomResults.value = baseRecords + .map { it.toFeedItem(contactSnapshot) } + .also { + Timber.tag(TAG).d( + "FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results" + ) + } } } + + + // ── WebSocket payment event listener ─────────────────────────────────── viewModelScope.launch { WalletNotificationService.paymentEvents.collect { event -> - Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list") + Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing") syncManager.refresh() } } } + + // ── Private helper ───────────────────────────────────────────────────────── + // Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults. + // Contact links, alias, and fiat are intentionally left empty/null here — + // applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and + // timestamps. Full enrichment is provided by feedRepository.observeFeedItems() + // for all items that pass through the live feed path. + private fun PaymentRecord.toFeedItem( + contactSnapshot: Map> = emptyMap() + ): PaymentFeedItem = + this.toPaymentFeedItem( + linkedContacts = contactSnapshot[paymentHash] ?: emptyList(), + resolvedAlias = pubkey?.let { aliasRepo.aliases.value[it] }, + fiatCurrency = fiatCurrency.value, + fiatSatsPerUnit = fiatSatsPerUnit.value + ) + + } -// ── Factory ─────────────────────────────────────────────────────────────────── +// ── Factory ──────────────────────────────────────────────────────────────────── class HistoryViewModelFactory( - private val repo : WalletRepository, - private val aliasRepo : NodeAliasRepository, - private val paymentCache : PaymentCacheRepository, + private val repo : WalletRepository, + private val aliasRepo : NodeAliasRepository, + private val paymentCache : PaymentCacheRepository, private val contactRepo : ContactRepository, - private val fiatCurrency : StateFlow, - private val fiatSatsPerUnit: StateFlow + private val feedRepository : PaymentFeedRepository, + private val fiatCurrency : StateFlow, + private val fiatSatsPerUnit : StateFlow ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) { + "Unknown ViewModel class: ${modelClass.name}" + } @Suppress("UNCHECKED_CAST") return HistoryViewModel( repo = repo, aliasRepo = aliasRepo, paymentCache = paymentCache, contactRepo = contactRepo, + feedRepository = feedRepository, fiatCurrency = fiatCurrency, fiatSatsPerUnit = fiatSatsPerUnit ) as T } -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt index 021cd13..1ef8d26 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailDelegate.kt @@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history import timber.log.Timber import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.ui.DetailState @@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate" class PaymentDetailDelegate( private val repo : WalletRepository, private val paymentCache : PaymentCacheRepository, + private val aliasRepo : NodeAliasRepository, private val scope : CoroutineScope ) { private val _state = MutableStateFlow(DetailState.Idle) @@ -22,38 +24,67 @@ class PaymentDetailDelegate( fun load(checkingId: String, record: PaymentRecord? = null) { paymentCache.getCachedDetail(checkingId)?.let { - Timber.d("DETAIL [HIT] $checkingId — instant from cache") + Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache") _state.value = DetailState.Success(it) return } _state.value = if (record != null) { - Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord") + Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord") DetailState.Partial(record) } else { - Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner") + Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner") DetailState.Loading } scope.launch { val fromDb = paymentCache.getDetailFromDb(checkingId) if (fromDb != null) { - paymentCache.saveDetail(checkingId, fromDb) - _state.value = DetailState.Success(fromDb) - Timber.d("DETAIL [DB] $checkingId — served from Room") + val record = fromDb.details + val enriched = if ( + record != null && + record.pubkey.isNullOrBlank() && + !record.bolt11.isNullOrBlank() + ) { + Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey") + val pubkey = runCatching { + repo.decodeBolt11(record.bolt11!!).payee + }.getOrNull() + + if (pubkey != null) { + val alias = runCatching { + aliasRepo.resolve(pubkey) + }.getOrNull() + paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias) + Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias") + fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias)) + } else { + Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed") + fromDb + } + } else { + fromDb + } + + paymentCache.saveDetail(checkingId, enriched) + _state.value = DetailState.Success(enriched) + Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room") return@launch } + + runCatching { repo.getPaymentDetail(checkingId) } .onSuccess { detail -> - Timber.d("DETAIL [NET] $checkingId — fetched from network") + Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network") paymentCache.saveDetail(checkingId, detail) _state.value = DetailState.Success(detail) } .onFailure { e -> - Timber.w("DETAIL [ERR] $checkingId — ${e.message}") + Timber.tag(TAG).w("DETAIL [ERR] $checkingId — ${e.message}") if (_state.value !is DetailState.Partial) { _state.value = DetailState.Error(e.message ?: "Failed to load payment") } } } } + fun clear() { _state.value = DetailState.Idle } } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt index d4353cd..5124745 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt @@ -54,6 +54,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem +import com.bitcointxoko.gudariwallet.data.model.toBaseRecord import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn import com.bitcointxoko.gudariwallet.ui.common.ContactAvatar @@ -68,41 +70,57 @@ import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatTimestamp import kotlinx.coroutines.launch -// ── Detail screen ────────────────────────────────────────────────────────────── +// ── Detail screen ───────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable fun PaymentDetailScreen( - payment : PaymentRecord, + item : PaymentFeedItem, // ← was: payment: PaymentRecord vm : HistoryViewModel, onBack : () -> Unit ) { - val strings = LocalAppStrings.current - val detailState by vm.detailState.collectAsState() - val fiatCurrency by vm.fiatCurrency.collectAsState() - val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() + val strings = LocalAppStrings.current + val detailState by vm.detailState.collectAsState() - LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) } + // fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem — + // no need to collect them from the ViewModel separately. + val fiatCurrency = item.fiatCurrency + val fiatSatsPerUnit = item.fiatSatsPerUnit + + // Load detail and clear on exit — keyed on paymentHash (supersedes checkingId) + LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) } DisposableEffect(Unit) { onDispose { vm.clearDetail() } } + // Enrich the base PaymentFeedItem's backing record with the detail response. + // PaymentDetailContent still operates on PaymentRecord for technical fields. + // enriched falls back through partial → base PaymentRecord via toDomain() + // if the detail fetch is still in flight or has failed. val enriched: PaymentRecord = when (val s = detailState) { is DetailState.Success -> { val detail = s.detail.details when { - detail == null -> payment + detail == null -> item.toBaseRecord() else -> detail.copy( - memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, - bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11 + memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo, + bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11, + pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey, + alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias ) } } - is DetailState.Partial -> s.record - else -> payment + is DetailState.Partial -> s.record.let { r -> + r.copy( + pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey, + alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias + ) + } + else -> item.toBaseRecord() } - // ── Contact state ───────────────────────────────────────────────────────── - val linkedContacts by remember(payment.checkingId) { - vm.contactsForPayment(payment.checkingId) + + // ── Contact state ────────────────────────────────────────────────────────── + val linkedContacts by remember(item.paymentHash) { + vm.contactsForPayment(item.paymentHash) }.collectAsStateWithLifecycle() val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle() @@ -110,8 +128,7 @@ fun PaymentDetailScreen( var showContactPicker by remember { mutableStateOf(false) } var showCreateContact by remember { mutableStateOf(false) } - // Best-effort lnAddress to pre-fill CreateContactSheet: - // prefer extra.comment if it looks like a Lightning Address, else null + // Best-effort lnAddress to pre-fill CreateContactSheet val paymentLnAddress = remember(enriched) { enriched.extra?.comment ?.takeIf { it.contains("@") && !it.contains(" ") } @@ -122,7 +139,7 @@ fun PaymentDetailScreen( TopAppBar( title = { Text( - if (payment.isOutgoing) strings.history.detailOutgoingTitle + if (item.isOutgoing) strings.history.detailOutgoingTitle else strings.history.detailIncomingTitle ) }, @@ -140,8 +157,8 @@ fun PaymentDetailScreen( when (detailState) { is DetailState.Loading -> { Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } @@ -165,7 +182,7 @@ fun PaymentDetailScreen( linkedContacts = linkedContacts, onAssignContact = { showContactPicker = true }, onUnassignContact = { contactId -> - vm.unassignContact(payment.checkingId, contactId) + vm.unassignContact(item.paymentHash, contactId) } ) } @@ -179,7 +196,7 @@ fun PaymentDetailScreen( linkedContacts = linkedContacts, onAssignContact = { showContactPicker = true }, onUnassignContact = { contactId -> - vm.unassignContact(payment.checkingId, contactId) + vm.unassignContact(item.paymentHash, contactId) }, modifier = Modifier.padding(padding) ) @@ -187,12 +204,12 @@ fun PaymentDetailScreen( } } - // ── Contact picker sheet ────────────────────────────────────────────────── + // ── Contact picker sheet ─────────────────────────────────────────────────── if (showContactPicker) { ContactPickerSheet( contacts = allContacts, onContactSelected = { contactId -> - vm.assignContact(payment.checkingId, contactId) + vm.assignContact(item.paymentHash, contactId) showContactPicker = false }, onCreateNew = { @@ -203,7 +220,7 @@ fun PaymentDetailScreen( ) } - // ── Create & assign contact sheet ───────────────────────────────────────── + // ── Create & assign contact sheet ────────────────────────────────────────── if (showCreateContact) { val addressType = if (paymentLnAddress != null) PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS @@ -211,8 +228,8 @@ fun PaymentDetailScreen( CreateContactSheet( initialAddressType = addressType, initialAddress = paymentLnAddress, - onSave = { name, type, address -> - vm.saveContactAndAssign(payment.checkingId, name, type, address) + onSave = { name, type, address -> + vm.saveContactAndAssign(item.paymentHash, name, type, address) showCreateContact = false }, onDismiss = { showCreateContact = false } @@ -221,19 +238,26 @@ fun PaymentDetailScreen( } // ── Detail content ───────────────────────────────────────────────────────────── +// Receives the enriched PaymentRecord from detailState — all technical fields +// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here. +// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item). @Composable private fun PaymentDetailContent( - payment : PaymentRecord, - fiatCurrency : String?, - fiatSatsPerUnit : Double?, - linkedContacts : List, + payment : PaymentRecord, + fiatCurrency : String?, + fiatSatsPerUnit : Double?, + linkedContacts : List, onAssignContact : () -> Unit, onUnassignContact : (contactId: String) -> Unit, - modifier : Modifier = Modifier + modifier : Modifier = Modifier ) { - val strings = LocalAppStrings.current - val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) + val strings = LocalAppStrings.current + val amountColor = paymentAmountColor( + isPending = payment.pending, + isFailed = payment.status == "failed", + isOutgoing = payment.isOutgoing + ) val context = LocalContext.current val clipboard = LocalClipboard.current @@ -254,8 +278,8 @@ private fun PaymentDetailContent( } LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -263,8 +287,8 @@ private fun PaymentDetailContent( item { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Icon( imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward @@ -275,11 +299,11 @@ private fun PaymentDetailContent( ) Spacer(Modifier.height(8.dp)) AmountWithFiatColumn( - amountSat = payment.amountSat, - isOutgoing = payment.isOutgoing, - amountColor = amountColor, - fiatLine = heroFiat, - style = MaterialTheme.typography.headlineLarge, + amountSat = payment.amountSat, + isOutgoing = payment.isOutgoing, + amountColor = amountColor, + fiatLine = heroFiat, + style = MaterialTheme.typography.headlineLarge, horizontalAlignment = Alignment.CenterHorizontally ) if (payment.feeSat > 0) { @@ -306,7 +330,6 @@ private fun PaymentDetailContent( // ── Contact section ──────────────────────────────────────────────────── item { if (linkedContacts.isEmpty()) { - // No contacts — just show an assign button OutlinedButton( onClick = onAssignContact, modifier = Modifier.fillMaxWidth() @@ -318,19 +341,19 @@ private fun PaymentDetailContent( ) Spacer(Modifier.width(6.dp)) Text("Assign contact") + // Text(strings.history.detailAssignContact) } } else { - // Show contacts with avatar + name, plus edit button Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { - // Header row: title + edit button Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = "Contacts", +// text = strings.history.detailContactsTitle, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -340,24 +363,21 @@ private fun PaymentDetailContent( ) { Icon( imageVector = Icons.Default.Edit, - contentDescription = "Edit contacts", + contentDescription = "Edit", +// contentDescription = strings.history.detailEditContacts, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary ) } } - Spacer(Modifier.height(8.dp)) - - // Each linked contact: avatar + name + remove linkedContacts.forEachIndexed { index, contact -> val name = contact.localAlias ?: contact.displayName ?: contact.name ?: "Unknown" - Row( - modifier = Modifier + modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically @@ -365,8 +385,8 @@ private fun PaymentDetailContent( ContactAvatar(name = name) Spacer(Modifier.width(12.dp)) Text( - text = name, - style = MaterialTheme.typography.bodyLarge, + text = name, + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) IconButton( @@ -375,13 +395,13 @@ private fun PaymentDetailContent( ) { Icon( imageVector = Icons.Default.Close, - contentDescription = "Remove contact", + contentDescription = "Remove", +// contentDescription = strings.history.detailRemoveContact, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } - if (index < linkedContacts.lastIndex) { HorizontalDivider( thickness = 0.5.dp, @@ -394,7 +414,6 @@ private fun PaymentDetailContent( } } - // ── Basic info ───────────────────────────────────────────────────────── item { DetailSection(title = strings.history.detailSectionDetails) { @@ -419,8 +438,8 @@ private fun PaymentDetailContent( payment.extra?.successAction?.let { action -> item { DetailSection(title = strings.history.detailSectionSuccessAction) { - action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } - action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } + action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } + action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } } } @@ -430,11 +449,11 @@ private fun PaymentDetailContent( item { DetailSection(title = strings.history.detailSectionTechnical) { DetailRow( - label = strings.history.detailRowPaymentHash, - value = payment.paymentHash, + label = strings.history.detailRowPaymentHash, + value = payment.paymentHash, monospace = true, copyable = true, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash)) @@ -444,11 +463,11 @@ private fun PaymentDetailContent( ) if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { DetailRow( - label = strings.history.detailRowPreimage, - value = payment.preimage, + label = strings.history.detailRowPreimage, + value = payment.preimage, monospace = true, copyable = true, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("Preimage", payment.preimage)) @@ -461,11 +480,11 @@ private fun PaymentDetailContent( val ambossUrl = "https://amboss.space/node/$pubkey" val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}" DetailRow( - label = strings.history.detailRowDestination, - value = label, - valueColor = MaterialTheme.colorScheme.primary, - textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, - onValueClick = { + label = strings.history.detailRowDestination, + value = label, + valueColor = MaterialTheme.colorScheme.primary, + textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, + onValueClick = { context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) }, trailingContent = { @@ -475,12 +494,12 @@ private fun PaymentDetailContent( } if (!payment.bolt11.isNullOrBlank()) { DetailRow( - label = strings.history.detailRowBolt11, - value = payment.bolt11, + label = strings.history.detailRowBolt11, + value = payment.bolt11, monospace = true, copyable = true, maxLines = 3, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11)) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt index 1a6367e..4fb5f67 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentFilter.kt @@ -1,6 +1,7 @@ package com.bitcointxoko.gudariwallet.ui.history import com.bitcointxoko.gudariwallet.api.PaymentRecord +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem import java.time.DayOfWeek import java.time.ZonedDateTime import java.time.temporal.ChronoUnit @@ -65,3 +66,51 @@ fun List.applyInMemoryFilters(f: PaymentFilter): List + if (f.statuses.isEmpty()) list + else list.filter { record -> + f.statuses.any { status -> + when (status) { + StatusFilter.COMPLETED -> !record.pending && record.status == "complete" + StatusFilter.PENDING -> record.pending + StatusFilter.FAILED -> record.status == "failed" + } + } + } + } + +fun List.applyFeedFilters(f: PaymentFilter): List = + distinctBy { it.paymentHash } + .let { list -> + when (f.direction) { + DirectionFilter.ALL -> list + DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } + DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } + } + } + .let { list -> + if (f.types.isEmpty()) list + else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization + } + .let { list -> + if (f.statuses.isEmpty()) list + else list.filter { item -> + f.statuses.any { status -> + when (status) { + StatusFilter.COMPLETED -> !item.isPending && !item.isFailed + StatusFilter.PENDING -> item.isPending + StatusFilter.FAILED -> item.isFailed + } + } + } + } + + .let { list -> + if (f.searchQuery.isBlank()) list + else list.filter { item -> + item.memo?.contains(f.searchQuery, ignoreCase = true) == true + || item.contactName?.contains(f.searchQuery, ignoreCase = true) == true + || item.alias?.contains(f.searchQuery, ignoreCase = true) == true + || item.paymentHash.startsWith(f.searchQuery, ignoreCase = true) + } + } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt index 00b025d..d984f20 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentRow.kt @@ -22,34 +22,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.LocalAppStrings +import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatTimestamp -import com.bitcointxoko.gudariwallet.util.isFailedStatus -import com.bitcointxoko.gudariwallet.util.isPendingStatus @Composable internal fun PaymentRow( - payment : PaymentRecord, - fiatCurrency : String?, - fiatSatsPerUnit : Double?, - contactName : String? = null, - onClick : () -> Unit + item : PaymentFeedItem, + onClick : () -> Unit ) { - val strings = LocalAppStrings.current - val isPending = isPendingStatus(payment.status) - val isFailed = isFailedStatus(payment.status) - val semantic = semanticColors() - val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) - val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward - else Icons.Default.ArrowDownward + val strings = LocalAppStrings.current + val semantic = semanticColors() - val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) { - if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0) - formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency) + val amountColor = paymentAmountColor( + isPending = item.isPending, + isFailed = item.isFailed, + isOutgoing = item.isOutgoing + ) + val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward + + val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) { + if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0) + formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) else null } @@ -61,46 +58,47 @@ internal fun PaymentRow( verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = icon, - contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent" - else strings.history.paymentRowReceivedCD, // ← "Received" - tint = amountColor, - modifier = Modifier.size(20.dp) + imageVector = icon, + contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD + else strings.history.paymentRowReceivedCD, + tint = amountColor, + modifier = Modifier.size(20.dp) ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = contactName // ← contact name first - ?: payment.memo?.takeIf { it.isNotBlank() } - ?: strings.history.paymentRowNoMemo, + text = item.contactName // contact name first + ?: item.memo?.takeIf { it.isNotBlank() } // then memo + ?: strings.history.paymentRowNoMemo, // then fallback style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) Spacer(Modifier.height(2.dp)) - val formattedTime = remember(payment.createdAt ?: payment.time) { - formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday) + val formattedTime = remember(item.createdAt ?: item.time) { + formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday) } Text( text = formattedTime, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + val statusLabel = when { - isPending -> strings.history.paymentRowPending - isFailed -> strings.history.paymentRowFailed - else -> null + item.isPending -> strings.history.paymentRowPending + item.isFailed -> strings.history.paymentRowFailed + else -> null } val statusColor = when { - isPending -> semantic.warningContainer - isFailed -> semantic.errorContainer - else -> semantic.successContainer // fallback, won't be used + item.isPending -> semantic.warningContainer + item.isFailed -> semantic.errorContainer + else -> semantic.successContainer } val statusContentColor = when { - isPending -> semantic.onWarningContainer - isFailed -> semantic.onErrorContainer - else -> semantic.onSuccessContainer + item.isPending -> semantic.onWarningContainer + item.isFailed -> semantic.onErrorContainer + else -> semantic.onSuccessContainer } if (statusLabel != null) { Spacer(Modifier.height(4.dp)) @@ -119,10 +117,25 @@ internal fun PaymentRow( } Spacer(Modifier.width(12.dp)) AmountWithFiatColumn( - amountSat = payment.amountSat, - isOutgoing = payment.isOutgoing, + amountSat = item.amountSat, + isOutgoing = item.isOutgoing, amountColor = amountColor, fiatLine = fiatLine ) } } + +@Composable +internal fun paymentAmountColor( + isPending : Boolean, + isFailed : Boolean, + isOutgoing : Boolean +): androidx.compose.ui.graphics.Color { + val semantic = semanticColors() + return when { + isPending -> semantic.onWarningContainer + isFailed -> MaterialTheme.colorScheme.error + isOutgoing -> MaterialTheme.colorScheme.onSurface + else -> semantic.onSuccessContainer + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt index 6f18c32..b319659 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt @@ -71,7 +71,7 @@ import kotlin.time.Duration.Companion.milliseconds fun ReceiveScreen( vm : WalletViewModel, nfcVm : NfcViewModel, - onNavigateToPaymentDetail: (checkingId: String) -> Unit + onNavigateToPaymentDetail : (paymentHash: String) -> Unit ) { val strings = LocalAppStrings.current val context = LocalContext.current @@ -162,7 +162,7 @@ fun ReceiveScreen( amountSats = state.amountSats, fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency), subLine = state.memo?.takeIf { it.isNotBlank() }, - onDetails = { onNavigateToPaymentDetail(state.checkingId) }, + onDetails = { onNavigateToPaymentDetail(state.paymentHash) }, secondaryLabel = strings.done, onSecondary = { vm.resetReceiveState() }, lnurl = null, // sender identity not available diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt index 4527ff9..d9a3511 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveViewModel.kt @@ -36,7 +36,7 @@ class ReceiveViewModel( _receiveState.value = ReceiveState.PaymentReceived( amountSats = event.amountSats, memo = event.memo, - checkingId = rs.paymentHash + paymentHash = rs.paymentHash ) } }