bug fixes

This commit is contained in:
2026-06-15 00:33:29 +02:00
parent 3c5539d2cb
commit 5f0badcdf5
17 changed files with 694 additions and 366 deletions
@@ -17,6 +17,11 @@ class ContactRepository(app: Application) {
fun observeContact(id: String): Flow<ContactWithAddresses?> =
dao.observeById(id)
/** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>> =
dao.observePaymentContactSummaries()
// --- Create ---
suspend fun createContact(
@@ -189,4 +194,7 @@ class ContactRepository(app: Application) {
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
dao.findPaymentHashesByContactIds(contactIds)
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity> =
dao.getContactsForPayment(paymentHash)
}
@@ -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<List<PaymentRecordEntity>> = dao.observeAll()
// ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */
@@ -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 paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val nodeAliasRepository : NodeAliasRepository,
private val fiatRateCache: FiatRateCache
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<List<PaymentFeedItem>> = combine(
paymentDao.observeAll(),
contactDao.observePaymentContactSummaries(),
paymentCache.observeAll(),
contactRepo.observePaymentContactSummaries(),
nodeAliasRepository.aliases,
fiatRateCache.fiatFlow
) { payments, contactSummaries, aliasMap, (fiatCurrency, fiatSatsPerUnit) ->
fiatVm.fiatFlow
) { payments: List<PaymentRecordEntity>, 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<String, List<PaymentContactSummary>> =
contactSummaries.groupBy { it.paymentHash }
@@ -50,12 +36,12 @@ class PaymentFeedRepository(
val resolvedAlias: String? = entity.pubkey?.let { aliasMap[it] }
entity.toDomain().toPaymentFeedItem(
entity.toPaymentFeedItem(
linkedContacts = linked,
resolvedAlias = resolvedAlias, // mapper falls back to entity.alias if null
resolvedAlias = resolvedAlias,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit
)
}
}
}.distinctUntilChanged()
}
@@ -157,4 +157,12 @@ interface ContactDao {
WHERE contactId IN (:contactIds)
""")
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
@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<ContactEntity>
}
@@ -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
)
@@ -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<ContactSummary>,
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<ContactSummary>,
resolvedAlias: String?, // from NodeAliasCache; fallback to payment.alias handled here
@@ -27,7 +27,7 @@ sealed class ReceiveState {
data class PaymentReceived(
val amountSats: Long,
val memo : String?,
val checkingId : String
val paymentHash : String
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady(
@@ -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,8 +377,8 @@ fun WalletScreen(
ReceiveScreen(
vm = vm,
nfcVm = nfcVm,
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$paymentHash")
}
)
}
@@ -376,9 +386,9 @@ fun WalletScreen(
SendScreen(
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,8 +432,8 @@ 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}")
}
)
}
@@ -431,25 +441,21 @@ fun WalletScreen(
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
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,
item = item,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
LaunchedEffect(paymentHash) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -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<Pair<String?, Double?>> = 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() }
@@ -56,28 +56,29 @@ 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 state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
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 listState = rememberLazyListState()
val contactNames by vm.contactNames.collectAsState()
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
// they are now embedded in each PaymentFeedItem
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
@@ -90,12 +91,12 @@ fun HistoryScreen(
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,32 +173,12 @@ fun HistoryScreen(
)
}
// ── Content ──────────────────────────────────────────────────────
// ── Content ────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is HistoryState.Error -> {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) {
Text(strings.history.historyRetry) // ← "Retry"
}
}
}
is HistoryState.Success -> {
// ── 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()
@@ -209,9 +190,9 @@ fun HistoryScreen(
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
if (items.isEmpty()) {
Text(
text = strings.history.historyNoPayments, // ← "No payments yet."
text = strings.history.historyNoPayments,
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -227,16 +208,13 @@ fun HistoryScreen(
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = s.payments,
items = items,
key = { it.paymentHash },
contentType = { "payment" }
) { payment ->
) { item ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.paymentHash],
onClick = { onPaymentClick(payment) }
item = item,
onClick = { onPaymentClick(item) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
@@ -262,11 +240,41 @@ fun HistoryScreen(
}
}
}
// ── 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),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = s.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) {
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,
@@ -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,7 +364,7 @@ private fun SearchBar(
}
}
// ── Active filter chips ──────────────────────────────────────────────────────
// ── Active filter chips ──────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ActiveFilterChips(
@@ -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) // ← "XY 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( // ← "XY"
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)
)
}
@@ -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)
@@ -54,7 +58,9 @@ class HistoryViewModel(
)
// ── 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,
@@ -84,46 +90,63 @@ class HistoryViewModel(
// ── Detail delegate ────────────────────────────────────────────────────────
private val detailDelegate = PaymentDetailDelegate(
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
scope = viewModelScope
)
val detailState: StateFlow<DetailState> = 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<PaymentFilter> = _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<List<String>> = feedItems
@@ -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
@@ -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<String, List<ContactSummary>> =
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<String, List<ContactSummary>> = 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
}
}
@@ -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>(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 }
}
@@ -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()
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
)
},
@@ -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
@@ -212,7 +229,7 @@ fun PaymentDetailScreen(
initialAddressType = addressType,
initialAddress = paymentLnAddress,
onSave = { name, type, address ->
vm.saveContactAndAssign(payment.checkingId, name, type, address)
vm.saveContactAndAssign(item.paymentHash, name, type, address)
showCreateContact = false
},
onDismiss = { showCreateContact = false }
@@ -221,6 +238,9 @@ 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(
@@ -233,7 +253,11 @@ private fun PaymentDetailContent(
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val amountColor = paymentAmountColor(
isPending = payment.pending,
isFailed = payment.status == "failed",
isOutgoing = payment.isOutgoing
)
val context = LocalContext.current
val clipboard = LocalClipboard.current
@@ -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,12 +341,11 @@ 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(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -331,6 +353,7 @@ private fun PaymentDetailContent(
) {
Text(
text = "Contacts",
// text = strings.history.detailContactsTitle,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -340,22 +363,19 @@ 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
.fillMaxWidth()
@@ -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) {
@@ -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<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types }
}
.let { 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<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
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)
}
}
@@ -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,
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 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
}
@@ -62,8 +59,8 @@ internal fun PaymentRow(
) {
Icon(
imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
else strings.history.paymentRowReceivedCD, // ← "Received"
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
else strings.history.paymentRowReceivedCD,
tint = amountColor,
modifier = Modifier.size(20.dp)
)
@@ -71,35 +68,36 @@ internal fun PaymentRow(
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
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
item.isPending -> semantic.onWarningContainer
item.isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer
}
if (statusLabel != null) {
@@ -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
}
}
@@ -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
@@ -36,7 +36,7 @@ class ReceiveViewModel(
_receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats,
memo = event.memo,
checkingId = rs.paymentHash
paymentHash = rs.paymentHash
)
}
}