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/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 index 10b5a32..7fedbc3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentFeedRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/PaymentFeedRepository.kt @@ -1,45 +1,31 @@ package com.bitcointxoko.gudariwallet.data -import com.bitcointxoko.gudariwallet.data.db.ContactDao import com.bitcointxoko.gudariwallet.data.db.PaymentContactSummary -import com.bitcointxoko.gudariwallet.data.db.PaymentDao -import com.bitcointxoko.gudariwallet.data.db.toDomain +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 -/** - * Assembles the unified [PaymentFeedItem] stream by combining all four - * independent data sources: - * 1. payment_records — via [PaymentDao.observeAll] - * 2. tx_contact_links — via [ContactDao.observePaymentContactSummaries] - * 3. node_alias_cache — via [NodeAliasRepository.aliases] - * 4. fiat rate — via [FiatRateCache.fiatFlow] - * - * This repository does not own any of these sources — it only combines them. - * Filtering, searching, and sorting are the responsibility of the ViewModel. - */ class PaymentFeedRepository( - private val paymentDao: PaymentDao, - private val contactDao: ContactDao, - private val nodeAliasRepository: NodeAliasRepository, - private val fiatRateCache: FiatRateCache + private val paymentCache : PaymentCacheRepository, + private val contactRepo : ContactRepository, + private val nodeAliasRepository : NodeAliasRepository, + private val fiatVm : FiatViewModel ) { - /** - * Emits a new list of [PaymentFeedItem] whenever any upstream source changes. - * Items are ordered newest-first (delegated to [PaymentDao.observeAll]). - */ fun observeFeedItems(): Flow> = combine( - paymentDao.observeAll(), - contactDao.observePaymentContactSummaries(), + paymentCache.observeAll(), + contactRepo.observePaymentContactSummaries(), nodeAliasRepository.aliases, - fiatRateCache.fiatFlow - ) { payments, contactSummaries, aliasMap, (fiatCurrency, fiatSatsPerUnit) -> + fiatVm.fiatFlow + ) { payments: List, contactSummaries, aliasMap, fiatRate -> + val fiatCurrency = fiatRate.first + val fiatSatsPerUnit = fiatRate.second - // Group contact summaries by paymentHash for O(1) lookup per payment val contactsByPayment: Map> = contactSummaries.groupBy { it.paymentHash } @@ -50,12 +36,12 @@ class PaymentFeedRepository( val resolvedAlias: String? = entity.pubkey?.let { aliasMap[it] } - entity.toDomain().toPaymentFeedItem( - linkedContacts = linked, - resolvedAlias = resolvedAlias, // mapper falls back to entity.alias if null - fiatCurrency = fiatCurrency, + 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 ee9bb8e..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 @@ -157,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/model/PaymentFeedItem.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt index 77100fc..08ab518 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItem.kt @@ -1,5 +1,7 @@ package com.bitcointxoko.gudariwallet.data.model +import com.bitcointxoko.gudariwallet.api.PaymentRecord + data class PaymentFeedItem( // ── Identity ───────────────────────────────────────────────────── @@ -42,3 +44,31 @@ data class PaymentFeedItem( 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 index 9ef33b2..ab04242 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItemMapper.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/model/PaymentFeedItemMapper.kt @@ -2,10 +2,31 @@ 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( @@ -18,6 +39,66 @@ fun ContactEntity.toContactSummary(): ContactSummary = ContactSummary( // ── 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 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 708a082..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 @@ -13,23 +13,27 @@ 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) @@ -47,24 +51,26 @@ class HistoryViewModel( // 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, + repo = repo, paymentCache = paymentCache, - scope = viewModelScope, + scope = viewModelScope, onNewPayments = { payments -> enricher.enrich(payments) } ) // ── Optimistic insert (called after a successful send) ───────────────────── - fun primePayment(record: PaymentRecord) { syncManager.primePayment(record) } + 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) } ) @@ -74,7 +80,7 @@ class HistoryViewModel( // 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 = { (state.value as? HistoryState.Success)?.canLoadMore == true @@ -83,47 +89,64 @@ class HistoryViewModel( // ── 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(paymentHash: String, record: PaymentRecord? = null) = detailDelegate.load(paymentHash, record) + fun clearDetail() = detailDelegate.clear() // ── 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(d: DirectionFilter) { _filter.update { it.copy(direction = d) } } + fun setSearchQuery(q: String) { + _filter.update { it.copy(searchQuery = q.trim()) } + } + + 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 (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() setDateFilter(min, max, preset) } + fun clearDateFilter() { _filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) } } + fun toggleContactFilter(contactId: String) { _filter.update { f -> val updated = if (contactId in f.contactIds) @@ -131,7 +154,10 @@ class HistoryViewModel( f.copy(contactIds = updated) } } - fun clearContactFilter() { _filter.update { it.copy(contactIds = emptySet()) } } + + fun clearContactFilter() { + _filter.update { it.copy(contactIds = emptySet()) } + } // ── Room-backed query results ────────────────────────────────────────────── // Null = use the live feed stream. @@ -148,9 +174,26 @@ class HistoryViewModel( _filter, _roomResults ) { feed, f, roomResults -> - val base = roomResults ?: feed - base.applyInMemoryFilters(f) - }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + 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()) // ── Available type filter chips ──────────────────────────────────────────── val availableTypes: StateFlow> = feedItems @@ -170,7 +213,7 @@ class HistoryViewModel( 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 } } @@ -180,8 +223,8 @@ class HistoryViewModel( else list.filter { item -> f.statuses.any { status -> when (status) { - StatusFilter.PENDING -> item.isPending - StatusFilter.FAILED -> item.isFailed + StatusFilter.PENDING -> item.isPending + StatusFilter.FAILED -> item.isFailed StatusFilter.COMPLETED -> !item.isPending && !item.isFailed } } @@ -192,7 +235,8 @@ class HistoryViewModel( else list.filter { it.tag in f.types } } .let { list -> - val min = f.minAmountSat; val max = f.maxAmountSat + val min = f.minAmountSat; + val max = f.maxAmountSat if (min == null && max == null) list else list.filter { item -> (min == null || item.amountSat >= min) && @@ -200,7 +244,8 @@ class HistoryViewModel( } } .let { list -> - val min = f.minCreatedAt; val max = f.maxCreatedAt + val min = f.minCreatedAt; + val max = f.maxCreatedAt if (min == null && max == null) list else list.filter { item -> val ts = item.createdAt ?: item.time @@ -233,8 +278,8 @@ class HistoryViewModel( } } .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyMap() ) @@ -242,16 +287,16 @@ class HistoryViewModel( fun contactsForPayment(paymentHash: String): StateFlow> = contactRepo.observeContactsForPayment(paymentHash) .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) fun allContacts(): StateFlow> = contactRepo.observeAllContacts() .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), initialValue = emptyList() ) @@ -266,19 +311,19 @@ class HistoryViewModel( } fun saveContactAndAssign( - paymentHash : String, - name : String, - addressType : PaymentAddressType?, - address : String? + paymentHash: String, + name: String, + addressType: PaymentAddressType?, + address: String? ) { 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 contact = contactRepo.createContact( + val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } + val contact = contactRepo.createContact( localAlias = name, - lnAddress = lnAddress, - lnUrl = lnUrl + lnAddress = lnAddress, + lnUrl = lnUrl ) Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking") contactRepo.linkPaymentToContact(paymentHash, contact.id) @@ -298,6 +343,7 @@ class HistoryViewModel( _filter.map { it.searchQuery }.debounce(300.milliseconds), _filter ) { _, f -> f } + .distinctUntilChanged() .collect { f -> if (!f.needsRoomQuery()) { Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed") @@ -305,13 +351,29 @@ class HistoryViewModel( return@collect } + // 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() } + .map { it.toFeedItem(contactSnapshot) } .also { Timber.tag(TAG).d( "FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results" @@ -326,7 +388,6 @@ class HistoryViewModel( // ── Text search ──────────────────────────────────────────── val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList() - // Supplement: query may match contact names not stored in payment_records if (f.searchQuery.isNotBlank()) { val contactHashes = contactRepo .findPaymentHashesByContactName(f.searchQuery) @@ -339,7 +400,7 @@ class HistoryViewModel( } _roomResults.value = baseRecords - .map { it.toFeedItem() } + .map { it.toFeedItem(contactSnapshot) } .also { Timber.tag(TAG).d( "FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results" @@ -348,6 +409,7 @@ class HistoryViewModel( } } + // ── WebSocket payment event listener ─────────────────────────────────── viewModelScope.launch { WalletNotificationService.paymentEvents.collect { event -> @@ -363,13 +425,17 @@ class HistoryViewModel( // 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(): PaymentFeedItem = - toPaymentFeedItem( - linkedContacts = emptyList(), - resolvedAlias = null, + 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 ──────────────────────────────────────────────────────────────────── @@ -399,3 +465,4 @@ class HistoryViewModelFactory( ) as T } } + 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 ) } }