Unify payment item #1

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