Merge pull request 'Unify payment item' (#1) from unify-payment-item into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -17,6 +17,11 @@ class ContactRepository(app: Application) {
|
||||
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
||||
dao.observeById(id)
|
||||
|
||||
/** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */
|
||||
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>> =
|
||||
dao.observePaymentContactSummaries()
|
||||
|
||||
|
||||
// --- Create ---
|
||||
|
||||
suspend fun createContact(
|
||||
@@ -189,4 +194,7 @@ class ContactRepository(app: Application) {
|
||||
|
||||
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
|
||||
dao.findPaymentHashesByContactIds(contactIds)
|
||||
|
||||
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity> =
|
||||
dao.getContactsForPayment(paymentHash)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.bitcointxoko.gudariwallet.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import timber.log.Timber
|
||||
|
||||
private const val TAG = "FiatRateCache"
|
||||
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
|
||||
*/
|
||||
class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||
|
||||
private val _fiatFlow = MutableStateFlow<Pair<String?, Double?>>(Pair(null, null))
|
||||
|
||||
/** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */
|
||||
val fiatFlow: StateFlow<Pair<String?, Double?>> = _fiatFlow.asStateFlow()
|
||||
|
||||
|
||||
// ── Rate ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private var cachedRate : Double? = null
|
||||
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||
cachedRate = rate
|
||||
rateLastFetchedAt = now
|
||||
cachedRateCurrency = currency
|
||||
_fiatFlow.value = Pair(currency, rate)
|
||||
fiatDataStore.persistRate(currency, rate)
|
||||
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
|
||||
}
|
||||
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||
cachedRate = null
|
||||
rateLastFetchedAt = 0L
|
||||
cachedRateCurrency = ""
|
||||
_fiatFlow.value = Pair(null, null)
|
||||
}
|
||||
|
||||
// ── Currency list ─────────────────────────────────────────────────────────
|
||||
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
|
||||
}
|
||||
|
||||
val currentRate: Double?
|
||||
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
||||
val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity
|
||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||
|
||||
|
||||
private const val TAG = "PaymentCacheRepository"
|
||||
|
||||
@@ -134,6 +137,9 @@ class PaymentCacheRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */
|
||||
fun observeAll(): Flow<List<PaymentRecordEntity>> = dao.observeAll()
|
||||
|
||||
// ── Payment detail ────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns a cached detail response, or null if not yet fetched this session. */
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
/** Flat projection used by PaymentFeedRepository to build ContactSummary lists. */
|
||||
data class PaymentContactSummary(
|
||||
val paymentHash: String,
|
||||
val contactId: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface ContactDao {
|
||||
|
||||
@@ -125,6 +132,17 @@ interface ContactDao {
|
||||
""")
|
||||
fun observePaymentContactNames(): Flow<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
|
||||
@Query("""
|
||||
SELECT tcl.paymentHash FROM tx_contact_links tcl
|
||||
@@ -139,4 +157,12 @@ interface ContactDao {
|
||||
WHERE contactId IN (:contactIds)
|
||||
""")
|
||||
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
|
||||
|
||||
@Query("""
|
||||
SELECT c.* FROM contacts c
|
||||
INNER JOIN tx_contact_links x ON c.id = x.contactId
|
||||
WHERE x.paymentHash = :paymentHash
|
||||
""")
|
||||
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity>
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PaymentDao {
|
||||
|
||||
@@ -56,6 +58,10 @@ interface PaymentDao {
|
||||
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
||||
suspend fun getAll(): List<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. */
|
||||
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ sealed class ReceiveState {
|
||||
data class PaymentReceived(
|
||||
val amountSats: Long,
|
||||
val memo : String?,
|
||||
val checkingId : String
|
||||
val paymentHash : String
|
||||
) : ReceiveState()
|
||||
data class Error(val message: String) : ReceiveState()
|
||||
data class LnurlWithdrawReady(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.bitcointxoko.gudariwallet.ui
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import timber.log.Timber
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -72,7 +74,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
|
||||
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -90,13 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
|
||||
private const val ROUTE_HOME = "home"
|
||||
private const val ROUTE_SCANNER = "scanner"
|
||||
private const val ROUTE_HISTORY = "history"
|
||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
|
||||
private const val ROUTE_NWC = "nwc"
|
||||
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||
private const val ROUTE_CONTACTS = "contacts"
|
||||
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
vm : WalletViewModel,
|
||||
@@ -131,10 +134,17 @@ fun WalletScreen(
|
||||
aliasRepo = vm.aliasRepo,
|
||||
paymentCache = vm.paymentCache,
|
||||
contactRepo = vm.contactRepo,
|
||||
feedRepository = PaymentFeedRepository(
|
||||
paymentCache = vm.paymentCache,
|
||||
contactRepo = vm.contactRepo,
|
||||
nodeAliasRepository = vm.aliasRepo,
|
||||
fiatVm = vm.fiatVm
|
||||
),
|
||||
fiatCurrency = vm.selectedCurrency,
|
||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||
)
|
||||
}
|
||||
|
||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||
val nwcVm: NwcViewModel = viewModel(
|
||||
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
|
||||
@@ -367,8 +377,8 @@ fun WalletScreen(
|
||||
ReceiveScreen(
|
||||
vm = vm,
|
||||
nfcVm = nfcVm,
|
||||
onNavigateToPaymentDetail = { checkingId ->
|
||||
navController.navigate("payment_detail/$checkingId")
|
||||
onNavigateToPaymentDetail = { paymentHash ->
|
||||
navController.navigate("payment_detail/$paymentHash")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -376,9 +386,9 @@ fun WalletScreen(
|
||||
SendScreen(
|
||||
vm = vm,
|
||||
nfcVm = nfcVm,
|
||||
onViewDetails = { checkingId, record ->
|
||||
onViewDetails = { paymentHash, record ->
|
||||
historyVm.primePayment(record)
|
||||
navController.navigate("payment_detail/$checkingId") {
|
||||
navController.navigate("payment_detail/$paymentHash") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
@@ -422,8 +432,8 @@ fun WalletScreen(
|
||||
onClose = {
|
||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||
},
|
||||
onPaymentClick = { payment ->
|
||||
navController.navigate("payment_detail/${payment.checkingId}")
|
||||
onPaymentClick = { item ->
|
||||
navController.navigate("payment_detail/${item.paymentHash}")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -431,25 +441,21 @@ fun WalletScreen(
|
||||
// Payment detail — back → history (natural back stack)
|
||||
composable(
|
||||
route = ROUTE_PAYMENT_DETAIL,
|
||||
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
|
||||
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
|
||||
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
|
||||
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
|
||||
val payment = remember(historyState, fallbackState, checkingId) {
|
||||
(historyState as? HistoryState.Success)
|
||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||
?: (fallbackState as? HistoryState.Success)
|
||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
|
||||
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
|
||||
val item = remember(feedItems, paymentHash) {
|
||||
historyVm.findPayment(paymentHash)
|
||||
}
|
||||
if (payment != null) {
|
||||
if (item != null) {
|
||||
PaymentDetailScreen(
|
||||
payment = payment,
|
||||
item = item,
|
||||
vm = historyVm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
LaunchedEffect(checkingId) { historyVm.refresh() }
|
||||
LaunchedEffect(paymentHash) { historyVm.refresh() }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -95,6 +96,17 @@ class FiatViewModel(
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
/** Combined fiat stream for [PaymentFeedRepository] feed assembly. */
|
||||
val fiatFlow: StateFlow<Pair<String?, Double?>> = combine(
|
||||
selectedCurrency,
|
||||
fiatSatsPerUnit
|
||||
) { currency, rate -> Pair(currency, rate) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = Pair(null, null)
|
||||
)
|
||||
|
||||
// ── Called by BalanceViewModel after every successful balance refresh ─────
|
||||
fun onBalanceRefreshed() {
|
||||
viewModelScope.launch { fetchAndApplyFiatRate() }
|
||||
|
||||
@@ -56,28 +56,29 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── List screen ──────────────────────────────────────────────────────────────
|
||||
// ── List screen ───────────────────────────────────────────────────────────────
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(
|
||||
vm : HistoryViewModel,
|
||||
onClose : () -> Unit,
|
||||
onPaymentClick : (PaymentRecord) -> Unit
|
||||
onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val state by vm.filteredState.collectAsState()
|
||||
val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
|
||||
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
|
||||
val filter by vm.filter.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
val availableTypes by vm.availableTypes.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val contactNames by vm.contactNames.collectAsState()
|
||||
|
||||
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
|
||||
// they are now embedded in each PaymentFeedItem
|
||||
|
||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val dismissFilterSheet = { filterSheetVisible = false }
|
||||
@@ -90,12 +91,12 @@ fun HistoryScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(strings.history.historyTitle) }, // ← "History"
|
||||
title = { Text(strings.history.historyTitle) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.historyClose // ← "Close"
|
||||
contentDescription = strings.history.historyClose
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -109,7 +110,7 @@ fun HistoryScreen(
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = strings.history.historySearchPayments // ← "Search payments"
|
||||
contentDescription = strings.history.historySearchPayments
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +120,7 @@ fun HistoryScreen(
|
||||
IconButton(onClick = { filterSheetVisible = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FilterList,
|
||||
contentDescription = strings.history.historyFilterPayments // ← "Filter payments"
|
||||
contentDescription = strings.history.historyFilterPayments
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +141,7 @@ fun HistoryScreen(
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// ── Search bar ───────────────────────────────────────────────────
|
||||
// ── Search bar ─────────────────────────────────────────────────
|
||||
if (searchVisible) {
|
||||
SearchBar(
|
||||
query = filter.searchQuery,
|
||||
@@ -154,7 +155,7 @@ fun HistoryScreen(
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// ── Active filter chips ──────────────────────────────────────────
|
||||
// ── Active filter chips ────────────────────────────────────────
|
||||
val searchActive = filter.searchQuery.isNotBlank()
|
||||
if (isFiltered || searchActive) {
|
||||
ActiveFilterChips(
|
||||
@@ -172,32 +173,12 @@ fun HistoryScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────
|
||||
// ── Content ────────────────────────────────────────────────────
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (val s = state) {
|
||||
|
||||
is HistoryState.Loading -> {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
is HistoryState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.refresh() }) {
|
||||
Text(strings.history.historyRetry) // ← "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is HistoryState.Success -> {
|
||||
// ── List — always rendered when state is Success, never unmounted ──────
|
||||
val s = state
|
||||
if (s is HistoryState.Success) {
|
||||
val shouldLoadMore by remember {
|
||||
derivedStateOf {
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
@@ -209,9 +190,9 @@ fun HistoryScreen(
|
||||
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
||||
}
|
||||
|
||||
if (s.payments.isEmpty()) {
|
||||
if (items.isEmpty()) {
|
||||
Text(
|
||||
text = strings.history.historyNoPayments, // ← "No payments yet."
|
||||
text = strings.history.historyNoPayments,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -227,16 +208,13 @@ fun HistoryScreen(
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(
|
||||
items = s.payments,
|
||||
items = items,
|
||||
key = { it.paymentHash },
|
||||
contentType = { "payment" }
|
||||
) { payment ->
|
||||
) { item ->
|
||||
PaymentRow(
|
||||
payment = payment,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
contactName = contactNames[payment.paymentHash],
|
||||
onClick = { onPaymentClick(payment) }
|
||||
item = item,
|
||||
onClick = { onPaymentClick(item) }
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
@@ -262,11 +240,41 @@ fun HistoryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── State overlays — sit on top of the list, never destroy it ─────────
|
||||
when (val s = state) {
|
||||
is HistoryState.Loading -> {
|
||||
// Only show the full-screen spinner on the very first load
|
||||
// (when we have no items yet). During loadMore(), the in-list
|
||||
// footer spinner handles it — don't obscure the results.
|
||||
if (items.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
is HistoryState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.refresh() }) {
|
||||
Text(strings.history.historyRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
is HistoryState.Success -> { /* handled above */ }
|
||||
}
|
||||
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
|
||||
if (filterSheetVisible) {
|
||||
FilterBottomSheet(
|
||||
currentFilter = filter,
|
||||
@@ -291,7 +299,7 @@ fun HistoryScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search bar ───────────────────────────────────────────────────────────────
|
||||
// ── Search bar ────────────────────────────────────────────────────────────────
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
query : String,
|
||||
@@ -312,13 +320,13 @@ private fun SearchBar(
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = strings.history.historySearchHelp // ← "Search help"
|
||||
contentDescription = strings.history.historySearchHelp
|
||||
)
|
||||
}
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
|
||||
placeholder = { Text(strings.history.historySearchPlaceholder) },
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
@@ -339,7 +347,7 @@ private fun SearchBar(
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentPaste,
|
||||
contentDescription = strings.history.historySearchPaste // ← "Paste"
|
||||
contentDescription = strings.history.historySearchPaste
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -348,7 +356,7 @@ private fun SearchBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.historySearchClear, // ← "Clear search"
|
||||
contentDescription = strings.history.historySearchClear,
|
||||
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
@@ -356,13 +364,13 @@ private fun SearchBar(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active filter chips ──────────────────────────────────────────────────────
|
||||
// ── Active filter chips ───────────────────────────────────────────────────────
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ActiveFilterChips(
|
||||
filter : PaymentFilter,
|
||||
searchActive : Boolean,
|
||||
onClearDirection: () -> Unit,
|
||||
onClearDirection : () -> Unit,
|
||||
onClearStatus : (StatusFilter) -> Unit,
|
||||
onClearType : (String) -> Unit,
|
||||
onClearAmount : () -> Unit,
|
||||
@@ -384,15 +392,15 @@ private fun ActiveFilterChips(
|
||||
label = {
|
||||
Text(
|
||||
when (filter.direction) {
|
||||
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
|
||||
else -> strings.history.filterDirectionIncoming // ← "Incoming"
|
||||
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
|
||||
else -> strings.history.filterDirectionIncoming
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
|
||||
contentDescription = strings.history.filterClearDirection,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -405,16 +413,16 @@ private fun ActiveFilterChips(
|
||||
label = {
|
||||
Text(
|
||||
when (s) {
|
||||
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
|
||||
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
|
||||
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
|
||||
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
|
||||
StatusFilter.PENDING -> strings.history.filterStatusPending
|
||||
StatusFilter.FAILED -> strings.history.filterStatusFailed
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
|
||||
contentDescription = strings.history.filterRemoveStatus,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -428,7 +436,7 @@ private fun ActiveFilterChips(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.filterRemoveType, // ← "Remove type filter"
|
||||
contentDescription = strings.history.filterRemoveType,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -437,11 +445,11 @@ private fun ActiveFilterChips(
|
||||
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||
val label = when {
|
||||
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats"
|
||||
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
|
||||
filter.minAmountSat != null ->
|
||||
strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
|
||||
strings.history.filterAmountMin(filter.minAmountSat)
|
||||
else ->
|
||||
strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
|
||||
strings.history.filterAmountMax(filter.maxAmountSat!!)
|
||||
}
|
||||
InputChip(
|
||||
selected = true,
|
||||
@@ -450,7 +458,7 @@ private fun ActiveFilterChips(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter"
|
||||
contentDescription = strings.history.filterClearAmount,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -460,19 +468,23 @@ private fun ActiveFilterChips(
|
||||
val maxCreatedAt = filter.maxCreatedAt
|
||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||
val label = when (filter.datePreset) {
|
||||
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
|
||||
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
|
||||
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
|
||||
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
|
||||
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
|
||||
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
|
||||
null -> when {
|
||||
minCreatedAt != null && maxCreatedAt != null ->
|
||||
strings.history.filterDateRange( // ← "X–Y"
|
||||
strings.history.filterDateRange(
|
||||
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
|
||||
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
|
||||
)
|
||||
minCreatedAt != null ->
|
||||
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
|
||||
strings.history.filterDateFrom(
|
||||
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
|
||||
)
|
||||
else ->
|
||||
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
|
||||
strings.history.filterDateUntil(
|
||||
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
|
||||
)
|
||||
}
|
||||
}
|
||||
InputChip(
|
||||
@@ -482,7 +494,7 @@ private fun ActiveFilterChips(
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.filterClearDate, // ← "Clear date filter"
|
||||
contentDescription = strings.history.filterClearDate,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -492,11 +504,11 @@ private fun ActiveFilterChips(
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSearch,
|
||||
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
|
||||
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = strings.history.historyClearSearch, // ← "Clear search"
|
||||
contentDescription = strings.history.historyClearSearch,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,25 +8,32 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
|
||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
|
||||
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
|
||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private const val TAG = "HistoryViewModel"
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@@ -35,20 +42,29 @@ class HistoryViewModel(
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val contactRepo : ContactRepository,
|
||||
private val feedRepository : PaymentFeedRepository,
|
||||
val fiatCurrency : StateFlow<String?>,
|
||||
val fiatSatsPerUnit : StateFlow<Double?>
|
||||
) : ViewModel() {
|
||||
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
|
||||
|
||||
// ── Sync manager ──────────────────────────────────────────────────────────
|
||||
// Owns the websocket/sync loop, pagination, and optimistic inserts.
|
||||
// Still operates on PaymentRecord internally — untouched by this refactor.
|
||||
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
||||
repo = repo,
|
||||
paymentCache = paymentCache,
|
||||
scope = viewModelScope,
|
||||
onNewPayments = { payments -> enricher.enrich(payments) }
|
||||
)
|
||||
|
||||
// ── Optimistic insert (called after a successful send) ─────────────────────
|
||||
fun primePayment(record: PaymentRecord) {
|
||||
syncManager.primePayment(record)
|
||||
}
|
||||
|
||||
// ── Enricher ──────────────────────────────────────────────────────────────
|
||||
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
|
||||
// which feeds into the feedRepository.observeFeedItems() combine.
|
||||
private val enricher: PaymentEnricher = PaymentEnricher(
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
@@ -58,101 +74,142 @@ class HistoryViewModel(
|
||||
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
|
||||
|
||||
fun refresh() = syncManager.refresh()
|
||||
fun loadMore() = syncManager.loadMore(
|
||||
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true }
|
||||
filteredCanLoadMore = {
|
||||
(state.value as? HistoryState.Success)?.canLoadMore == true
|
||||
}
|
||||
)
|
||||
|
||||
// ── Detail delegate ───────────────────────────────────────────────────────
|
||||
// ── Detail delegate ────────────────────────────────────────────────────────
|
||||
private val detailDelegate = PaymentDetailDelegate(
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
paymentCache = paymentCache,
|
||||
scope = viewModelScope
|
||||
)
|
||||
val detailState: StateFlow<DetailState> = detailDelegate.state
|
||||
fun loadDetail(checkingId: String, record: PaymentRecord? = null) =
|
||||
detailDelegate.load(checkingId, record)
|
||||
fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
|
||||
detailDelegate.load(paymentHash, record)
|
||||
|
||||
fun clearDetail() = detailDelegate.clear()
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────────────
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
private val _filter = MutableStateFlow(PaymentFilter())
|
||||
val filter: StateFlow<PaymentFilter> = _filter
|
||||
|
||||
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
|
||||
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
|
||||
|
||||
fun setDirectionFilter(direction: DirectionFilter) {
|
||||
_filter.update { it.copy(direction = direction) }
|
||||
fun setSearchQuery(q: String) {
|
||||
_filter.update { it.copy(searchQuery = q.trim()) }
|
||||
}
|
||||
fun toggleStatusFilter(status: StatusFilter) {
|
||||
|
||||
fun clearSearch() {
|
||||
_filter.update { it.copy(searchQuery = "") }
|
||||
}
|
||||
|
||||
fun setDirectionFilter(d: DirectionFilter) {
|
||||
_filter.update { it.copy(direction = d) }
|
||||
}
|
||||
|
||||
fun toggleStatusFilter(s: StatusFilter) {
|
||||
_filter.update { f ->
|
||||
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
|
||||
val updated = if (s in f.statuses) f.statuses - s else f.statuses + s
|
||||
f.copy(statuses = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTypeFilter(type: String) {
|
||||
_filter.update { f ->
|
||||
val updated = if (type in f.types) f.types - type else f.types + type
|
||||
f.copy(types = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAmountFilter(min: Long?, max: Long?) {
|
||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||
}
|
||||
|
||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||
}
|
||||
|
||||
fun applyDatePreset(preset: DatePreset) {
|
||||
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt
|
||||
val (min, max) = preset.toEpochSecondRange()
|
||||
setDateFilter(min, max, preset)
|
||||
}
|
||||
|
||||
fun clearDateFilter() {
|
||||
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
||||
}
|
||||
|
||||
fun findPayment(checkingId: String): PaymentRecord? {
|
||||
val fromFiltered = (filteredState.value as? HistoryState.Success)
|
||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||
if (fromFiltered != null) return fromFiltered
|
||||
return (state.value as? HistoryState.Success)
|
||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||
fun toggleContactFilter(contactId: String) {
|
||||
_filter.update { f ->
|
||||
val updated = if (contactId in f.contactIds)
|
||||
f.contactIds - contactId else f.contactIds + contactId
|
||||
f.copy(contactIds = updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearContactFilter() {
|
||||
_filter.update { it.copy(contactIds = emptySet()) }
|
||||
}
|
||||
|
||||
// ── Room-backed query results ──────────────────────────────────────────────
|
||||
private val _roomResults = MutableStateFlow<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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Distinct non-null tags present in the currently loaded list. */
|
||||
val availableTypes: StateFlow<List<String>> = syncManager.state
|
||||
.map { s ->
|
||||
if (s !is HistoryState.Success) emptyList()
|
||||
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
|
||||
// ── Feed items — single source of truth for the UI list ───────────────────
|
||||
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
|
||||
// source changes: payments DB, contact links, alias cache, fiat rate,
|
||||
// active filter, or Room query override.
|
||||
val feedItems: StateFlow<List<PaymentFeedItem>> = combine(
|
||||
feedRepository.observeFeedItems(),
|
||||
_filter,
|
||||
_roomResults
|
||||
) { feed, f, roomResults ->
|
||||
Triple(feed, f, roomResults)
|
||||
}
|
||||
.distinctUntilChanged { old, new ->
|
||||
// If room results are active on both sides and haven't changed, suppress
|
||||
val oldRoom = old.third
|
||||
val newRoom = new.third
|
||||
if (oldRoom != null && newRoom != null && oldRoom === newRoom && old.second == new.second) {
|
||||
return@distinctUntilChanged true // same room results, same filter — skip
|
||||
}
|
||||
false // otherwise let through
|
||||
}
|
||||
.map { (feed, f, roomResults) ->
|
||||
withContext(Dispatchers.Default) {
|
||||
roomResults?.applyFeedFilters(
|
||||
f.copy(searchQuery = "", contactIds = emptySet())
|
||||
) ?: feed.applyFeedFilters(f)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val filteredState = combine(
|
||||
syncManager.state,
|
||||
_filter, _roomResults,
|
||||
contactRepo.observeAllPaymentContactNames()
|
||||
) { s, f, roomResults, contactNames ->
|
||||
Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries")
|
||||
if (s !is HistoryState.Success) return@combine s
|
||||
val payments = if (roomResults == null) {
|
||||
s.payments.applyInMemoryFilters(f)
|
||||
} else {
|
||||
roomResults.applyInMemoryFilters(f)
|
||||
}
|
||||
s.copy(
|
||||
payments = payments,
|
||||
canLoadMore = roomResults == null && s.canLoadMore
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||
// ── Available type filter chips ────────────────────────────────────────────
|
||||
val availableTypes: StateFlow<List<String>> = feedItems
|
||||
.map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
// ── In-memory filter logic ────────────────────────────────────────────────
|
||||
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
||||
// ── Find a single item by paymentHash ─────────────────────────────────────
|
||||
// paymentHash supersedes checkingId as the stable unique identifier.
|
||||
// Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip.
|
||||
fun findPayment(paymentHash: String): PaymentFeedItem? =
|
||||
feedItems.value.firstOrNull { it.paymentHash == paymentHash }
|
||||
|
||||
// ── In-memory filter logic ─────────────────────────────────────────────────
|
||||
// All filter keys map directly to PaymentFeedItem fields — no extra
|
||||
// resolution needed (contact names, alias, fiat are already embedded).
|
||||
private fun List<PaymentFeedItem>.applyInMemoryFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||
distinctBy { it.paymentHash }
|
||||
.let { list ->
|
||||
when (f.direction) {
|
||||
@@ -161,22 +218,55 @@ class HistoryViewModel(
|
||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.statuses.isEmpty()) list
|
||||
else list.filter { item ->
|
||||
f.statuses.any { status ->
|
||||
when (status) {
|
||||
StatusFilter.PENDING -> item.isPending
|
||||
StatusFilter.FAILED -> item.isFailed
|
||||
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.extra?.tag in f.types }
|
||||
else list.filter { it.tag in f.types }
|
||||
}
|
||||
.let { list ->
|
||||
val min = f.minAmountSat;
|
||||
val max = f.maxAmountSat
|
||||
if (min == null && max == null) list
|
||||
else list.filter { item ->
|
||||
(min == null || item.amountSat >= min) &&
|
||||
(max == null || item.amountSat <= max)
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
val min = f.minCreatedAt;
|
||||
val max = f.maxCreatedAt
|
||||
if (min == null && max == null) list
|
||||
else list.filter { item ->
|
||||
val ts = item.createdAt ?: item.time
|
||||
ts != null &&
|
||||
(min == null || ts >= min) &&
|
||||
(max == null || ts <= max)
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
val q = f.searchQuery
|
||||
if (q.isBlank()) list
|
||||
else list.filter { item ->
|
||||
item.memo?.contains(q, ignoreCase = true) == true ||
|
||||
item.contactName?.contains(q, ignoreCase = true) == true ||
|
||||
item.paymentHash.contains(q, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contact name map ─────────────────────────────────────────────────────
|
||||
// Maps checkingId → display name for any payment that has a linked contact.
|
||||
// Rebuilt whenever the underlying tx_contact_links or contacts tables change.
|
||||
val contactNames: StateFlow<Map<String, String>> =
|
||||
contactRepo.observeAllPaymentContactNames()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
// Maps contactId → display name (for filter chips)
|
||||
// ── Contact display names (for filter chips) ───────────────────────────────
|
||||
// Used to render contact names in active filter chips on the UI.
|
||||
// Payment row contact names are embedded in PaymentFeedItem.contactName.
|
||||
val contactDisplayNames: StateFlow<Map<String, String>> =
|
||||
contactRepo.observeAllContacts()
|
||||
.map { contacts ->
|
||||
@@ -193,8 +283,7 @@ class HistoryViewModel(
|
||||
initialValue = emptyMap()
|
||||
)
|
||||
|
||||
// ── Contact assignment ───────────────────────────────────────────────────
|
||||
|
||||
// ── Per-payment contact queries ────────────────────────────────────────────
|
||||
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
|
||||
contactRepo.observeContactsForPayment(paymentHash)
|
||||
.stateIn(
|
||||
@@ -211,26 +300,23 @@ class HistoryViewModel(
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
// ── Contact assignment ─────────────────────────────────────────────────────
|
||||
fun assignContact(paymentHash: String, contactId: String) {
|
||||
Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
|
||||
viewModelScope.launch {
|
||||
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
||||
}
|
||||
Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
|
||||
viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) }
|
||||
}
|
||||
|
||||
fun unassignContact(paymentHash: String, contactId: String) {
|
||||
viewModelScope.launch {
|
||||
contactRepo.unlinkPaymentFromContact(paymentHash, contactId)
|
||||
}
|
||||
viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) }
|
||||
}
|
||||
|
||||
fun saveContactAndAssign(
|
||||
paymentHash : String,
|
||||
name : String,
|
||||
addressType : PaymentAddressType?,
|
||||
address : String?
|
||||
paymentHash: String,
|
||||
name: String,
|
||||
addressType: PaymentAddressType?,
|
||||
address: String?
|
||||
) {
|
||||
Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
|
||||
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
|
||||
viewModelScope.launch {
|
||||
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||
@@ -239,112 +325,144 @@ class HistoryViewModel(
|
||||
lnAddress = lnAddress,
|
||||
lnUrl = lnUrl
|
||||
)
|
||||
Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash")
|
||||
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
|
||||
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleContactFilter(contactId: String) {
|
||||
_filter.update { f ->
|
||||
val updated = if (contactId in f.contactIds)
|
||||
f.contactIds - contactId else f.contactIds + contactId
|
||||
f.copy(contactIds = updated)
|
||||
}
|
||||
}
|
||||
fun clearContactFilter() {
|
||||
_filter.update { it.copy(contactIds = emptySet()) }
|
||||
}
|
||||
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
init {
|
||||
syncManager.init()
|
||||
|
||||
// ── Room query path ────────────────────────────────────────────────────
|
||||
// Activated when the filter requires DB-level text search or contact-ID
|
||||
// lookup. Results are stored in _roomResults and override the live feed
|
||||
// stream in feedItems. Cleared back to null when no Room query is needed.
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
_filter.map { it.searchQuery }.debounce(300.milliseconds),
|
||||
_filter
|
||||
) { _, f -> f }
|
||||
.distinctUntilChanged()
|
||||
.collect { f ->
|
||||
if (!f.needsRoomQuery()) {
|
||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
||||
Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
|
||||
_roomResults.value = null
|
||||
} else {
|
||||
// ── Room query path ───────────────────────────────────
|
||||
val baseResults = paymentCache.queryAll(f.searchQuery, f)
|
||||
.toMutableList()
|
||||
return@collect
|
||||
}
|
||||
|
||||
// Snapshot the contact summaries once per query — no new DAO needed.
|
||||
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
|
||||
// resolve contact names without per-row DB calls.
|
||||
val contactSnapshot: Map<String, List<ContactSummary>> =
|
||||
contactRepo.observePaymentContactSummaries()
|
||||
.first()
|
||||
.groupBy(
|
||||
keySelector = { it.paymentHash },
|
||||
valueTransform = {
|
||||
ContactSummary(
|
||||
id = it.contactId,
|
||||
displayName = it.displayName
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// ── Explicit contact-ID filter (highest priority) ──────────
|
||||
if (f.contactIds.isNotEmpty()) {
|
||||
val contactHashes = contactRepo
|
||||
.findPaymentHashesByContactIds(f.contactIds.toList())
|
||||
_roomResults.value = if (contactHashes.isNotEmpty()) {
|
||||
paymentCache.queryByPaymentHashes(contactHashes, f)
|
||||
.map { it.toFeedItem(contactSnapshot) }
|
||||
.also {
|
||||
Timber.tag(TAG).d(
|
||||
"FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
// ── Text search ────────────────────────────────────────────
|
||||
val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList()
|
||||
|
||||
// ── Supplemental contact search ───────────────────────
|
||||
// 1. Text query may match contact names not in payment_records
|
||||
if (f.searchQuery.isNotBlank()) {
|
||||
val contactPaymentHashes = contactRepo
|
||||
val contactHashes = contactRepo
|
||||
.findPaymentHashesByContactName(f.searchQuery)
|
||||
if (contactPaymentHashes.isNotEmpty()) {
|
||||
val contactPayments = paymentCache
|
||||
.queryByPaymentHashes(contactPaymentHashes, f)
|
||||
// Merge without duplicates
|
||||
val existingIds = baseResults.map { it.checkingId }.toSet()
|
||||
baseResults += contactPayments
|
||||
if (contactHashes.isNotEmpty()) {
|
||||
val existingIds = baseRecords.map { it.checkingId }.toSet()
|
||||
baseRecords += paymentCache
|
||||
.queryByPaymentHashes(contactHashes, f)
|
||||
.filter { it.checkingId !in existingIds }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Explicit contact ID filter
|
||||
if (f.contactIds.isNotEmpty()) {
|
||||
val contactPaymentHashes = contactRepo
|
||||
.findPaymentHashesByContactIds(f.contactIds.toList())
|
||||
if (contactPaymentHashes.isNotEmpty()) {
|
||||
// Fetch ALL payments for these contacts, WITH the full filter applied
|
||||
val contactFiltered = paymentCache.queryByPaymentHashes(
|
||||
paymentHashes = contactPaymentHashes,
|
||||
filter = f
|
||||
)
|
||||
_roomResults.value = contactFiltered
|
||||
_roomResults.value = baseRecords
|
||||
.map { it.toFeedItem(contactSnapshot) }
|
||||
.also {
|
||||
Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results")
|
||||
Timber.tag(TAG).d(
|
||||
"FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results"
|
||||
)
|
||||
}
|
||||
return@collect
|
||||
} else {
|
||||
// Selected contacts have no payments — return empty
|
||||
_roomResults.value = emptyList()
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
|
||||
_roomResults.value = baseResults.also {
|
||||
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket payment event listener ───────────────────────────────────
|
||||
viewModelScope.launch {
|
||||
WalletNotificationService.paymentEvents.collect { event ->
|
||||
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
||||
Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
|
||||
syncManager.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helper ─────────────────────────────────────────────────────────
|
||||
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
|
||||
// Contact links, alias, and fiat are intentionally left empty/null here —
|
||||
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
|
||||
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
|
||||
// for all items that pass through the live feed path.
|
||||
private fun PaymentRecord.toFeedItem(
|
||||
contactSnapshot: Map<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(
|
||||
private val repo : WalletRepository,
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val contactRepo : ContactRepository,
|
||||
private val feedRepository : PaymentFeedRepository,
|
||||
private val fiatCurrency : StateFlow<String?>,
|
||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||
private val fiatSatsPerUnit : StateFlow<Double?>
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
|
||||
"Unknown ViewModel class: ${modelClass.name}"
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return HistoryViewModel(
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
paymentCache = paymentCache,
|
||||
contactRepo = contactRepo,
|
||||
feedRepository = feedRepository,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-8
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import timber.log.Timber
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
|
||||
class PaymentDetailDelegate(
|
||||
private val repo : WalletRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val scope : CoroutineScope
|
||||
) {
|
||||
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
|
||||
|
||||
fun load(checkingId: String, record: PaymentRecord? = null) {
|
||||
paymentCache.getCachedDetail(checkingId)?.let {
|
||||
Timber.d("DETAIL [HIT] $checkingId — instant from cache")
|
||||
Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache")
|
||||
_state.value = DetailState.Success(it)
|
||||
return
|
||||
}
|
||||
_state.value = if (record != null) {
|
||||
Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
||||
Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
||||
DetailState.Partial(record)
|
||||
} else {
|
||||
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
||||
Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
||||
DetailState.Loading
|
||||
}
|
||||
scope.launch {
|
||||
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
||||
if (fromDb != null) {
|
||||
paymentCache.saveDetail(checkingId, fromDb)
|
||||
_state.value = DetailState.Success(fromDb)
|
||||
Timber.d("DETAIL [DB] $checkingId — served from Room")
|
||||
val record = fromDb.details
|
||||
val enriched = if (
|
||||
record != null &&
|
||||
record.pubkey.isNullOrBlank() &&
|
||||
!record.bolt11.isNullOrBlank()
|
||||
) {
|
||||
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey")
|
||||
val pubkey = runCatching {
|
||||
repo.decodeBolt11(record.bolt11!!).payee
|
||||
}.getOrNull()
|
||||
|
||||
if (pubkey != null) {
|
||||
val alias = runCatching {
|
||||
aliasRepo.resolve(pubkey)
|
||||
}.getOrNull()
|
||||
paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias)
|
||||
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias")
|
||||
fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias))
|
||||
} else {
|
||||
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed")
|
||||
fromDb
|
||||
}
|
||||
} else {
|
||||
fromDb
|
||||
}
|
||||
|
||||
paymentCache.saveDetail(checkingId, enriched)
|
||||
_state.value = DetailState.Success(enriched)
|
||||
Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
runCatching { repo.getPaymentDetail(checkingId) }
|
||||
.onSuccess { detail ->
|
||||
Timber.d("DETAIL [NET] $checkingId — fetched from network")
|
||||
Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
|
||||
paymentCache.saveDetail(checkingId, detail)
|
||||
_state.value = DetailState.Success(detail)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.w("DETAIL [ERR] $checkingId — ${e.message}")
|
||||
Timber.tag(TAG).w("DETAIL [ERR] $checkingId — ${e.message}")
|
||||
if (_state.value !is DetailState.Partial) {
|
||||
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() { _state.value = DetailState.Idle }
|
||||
}
|
||||
@@ -54,6 +54,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
|
||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||
import com.bitcointxoko.gudariwallet.ui.common.ContactAvatar
|
||||
@@ -68,41 +70,57 @@ import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── Detail screen ──────────────────────────────────────────────────────────────
|
||||
// ── Detail screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PaymentDetailScreen(
|
||||
payment : PaymentRecord,
|
||||
item : PaymentFeedItem, // ← was: payment: PaymentRecord
|
||||
vm : HistoryViewModel,
|
||||
onBack : () -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val detailState by vm.detailState.collectAsState()
|
||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||
|
||||
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
||||
// fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
|
||||
// no need to collect them from the ViewModel separately.
|
||||
val fiatCurrency = item.fiatCurrency
|
||||
val fiatSatsPerUnit = item.fiatSatsPerUnit
|
||||
|
||||
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
|
||||
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
|
||||
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||
|
||||
// Enrich the base PaymentFeedItem's backing record with the detail response.
|
||||
// PaymentDetailContent still operates on PaymentRecord for technical fields.
|
||||
// enriched falls back through partial → base PaymentRecord via toDomain()
|
||||
// if the detail fetch is still in flight or has failed.
|
||||
val enriched: PaymentRecord = when (val s = detailState) {
|
||||
is DetailState.Success -> {
|
||||
val detail = s.detail.details
|
||||
when {
|
||||
detail == null -> payment
|
||||
detail == null -> item.toBaseRecord()
|
||||
else -> detail.copy(
|
||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
|
||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
|
||||
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||
)
|
||||
}
|
||||
}
|
||||
is DetailState.Partial -> s.record
|
||||
else -> payment
|
||||
is DetailState.Partial -> s.record.let { r ->
|
||||
r.copy(
|
||||
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||
)
|
||||
}
|
||||
else -> item.toBaseRecord()
|
||||
}
|
||||
|
||||
// ── Contact state ─────────────────────────────────────────────────────────
|
||||
val linkedContacts by remember(payment.checkingId) {
|
||||
vm.contactsForPayment(payment.checkingId)
|
||||
|
||||
// ── Contact state ──────────────────────────────────────────────────────────
|
||||
val linkedContacts by remember(item.paymentHash) {
|
||||
vm.contactsForPayment(item.paymentHash)
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
||||
@@ -110,8 +128,7 @@ fun PaymentDetailScreen(
|
||||
var showContactPicker by remember { mutableStateOf(false) }
|
||||
var showCreateContact by remember { mutableStateOf(false) }
|
||||
|
||||
// Best-effort lnAddress to pre-fill CreateContactSheet:
|
||||
// prefer extra.comment if it looks like a Lightning Address, else null
|
||||
// Best-effort lnAddress to pre-fill CreateContactSheet
|
||||
val paymentLnAddress = remember(enriched) {
|
||||
enriched.extra?.comment
|
||||
?.takeIf { it.contains("@") && !it.contains(" ") }
|
||||
@@ -122,7 +139,7 @@ fun PaymentDetailScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (payment.isOutgoing) strings.history.detailOutgoingTitle
|
||||
if (item.isOutgoing) strings.history.detailOutgoingTitle
|
||||
else strings.history.detailIncomingTitle
|
||||
)
|
||||
},
|
||||
@@ -165,7 +182,7 @@ fun PaymentDetailScreen(
|
||||
linkedContacts = linkedContacts,
|
||||
onAssignContact = { showContactPicker = true },
|
||||
onUnassignContact = { contactId ->
|
||||
vm.unassignContact(payment.checkingId, contactId)
|
||||
vm.unassignContact(item.paymentHash, contactId)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -179,7 +196,7 @@ fun PaymentDetailScreen(
|
||||
linkedContacts = linkedContacts,
|
||||
onAssignContact = { showContactPicker = true },
|
||||
onUnassignContact = { contactId ->
|
||||
vm.unassignContact(payment.checkingId, contactId)
|
||||
vm.unassignContact(item.paymentHash, contactId)
|
||||
},
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
@@ -187,12 +204,12 @@ fun PaymentDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contact picker sheet ──────────────────────────────────────────────────
|
||||
// ── Contact picker sheet ───────────────────────────────────────────────────
|
||||
if (showContactPicker) {
|
||||
ContactPickerSheet(
|
||||
contacts = allContacts,
|
||||
onContactSelected = { contactId ->
|
||||
vm.assignContact(payment.checkingId, contactId)
|
||||
vm.assignContact(item.paymentHash, contactId)
|
||||
showContactPicker = false
|
||||
},
|
||||
onCreateNew = {
|
||||
@@ -203,7 +220,7 @@ fun PaymentDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create & assign contact sheet ─────────────────────────────────────────
|
||||
// ── Create & assign contact sheet ──────────────────────────────────────────
|
||||
if (showCreateContact) {
|
||||
val addressType = if (paymentLnAddress != null)
|
||||
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
||||
@@ -212,7 +229,7 @@ fun PaymentDetailScreen(
|
||||
initialAddressType = addressType,
|
||||
initialAddress = paymentLnAddress,
|
||||
onSave = { name, type, address ->
|
||||
vm.saveContactAndAssign(payment.checkingId, name, type, address)
|
||||
vm.saveContactAndAssign(item.paymentHash, name, type, address)
|
||||
showCreateContact = false
|
||||
},
|
||||
onDismiss = { showCreateContact = false }
|
||||
@@ -221,6 +238,9 @@ fun PaymentDetailScreen(
|
||||
}
|
||||
|
||||
// ── Detail content ─────────────────────────────────────────────────────────────
|
||||
// Receives the enriched PaymentRecord from detailState — all technical fields
|
||||
// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here.
|
||||
// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item).
|
||||
|
||||
@Composable
|
||||
private fun PaymentDetailContent(
|
||||
@@ -233,7 +253,11 @@ private fun PaymentDetailContent(
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||
val amountColor = paymentAmountColor(
|
||||
isPending = payment.pending,
|
||||
isFailed = payment.status == "failed",
|
||||
isOutgoing = payment.isOutgoing
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboard.current
|
||||
@@ -306,7 +330,6 @@ private fun PaymentDetailContent(
|
||||
// ── Contact section ────────────────────────────────────────────────────
|
||||
item {
|
||||
if (linkedContacts.isEmpty()) {
|
||||
// No contacts — just show an assign button
|
||||
OutlinedButton(
|
||||
onClick = onAssignContact,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -318,12 +341,11 @@ private fun PaymentDetailContent(
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Assign contact")
|
||||
// Text(strings.history.detailAssignContact)
|
||||
}
|
||||
} else {
|
||||
// Show contacts with avatar + name, plus edit button
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header row: title + edit button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -331,6 +353,7 @@ private fun PaymentDetailContent(
|
||||
) {
|
||||
Text(
|
||||
text = "Contacts",
|
||||
// text = strings.history.detailContactsTitle,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -340,22 +363,19 @@ private fun PaymentDetailContent(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = "Edit contacts",
|
||||
contentDescription = "Edit",
|
||||
// contentDescription = strings.history.detailEditContacts,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Each linked contact: avatar + name + remove
|
||||
linkedContacts.forEachIndexed { index, contact ->
|
||||
val name = contact.localAlias
|
||||
?: contact.displayName
|
||||
?: contact.name
|
||||
?: "Unknown"
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -375,13 +395,13 @@ private fun PaymentDetailContent(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Remove contact",
|
||||
contentDescription = "Remove",
|
||||
// contentDescription = strings.history.detailRemoveContact,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index < linkedContacts.lastIndex) {
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
@@ -394,7 +414,6 @@ private fun PaymentDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Basic info ─────────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = strings.history.detailSectionDetails) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.history
|
||||
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||
import java.time.DayOfWeek
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
@@ -65,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.extra?.tag in f.types }
|
||||
}
|
||||
.let { list ->
|
||||
if (f.statuses.isEmpty()) list
|
||||
else list.filter { record ->
|
||||
f.statuses.any { status ->
|
||||
when (status) {
|
||||
StatusFilter.COMPLETED -> !record.pending && record.status == "complete"
|
||||
StatusFilter.PENDING -> record.pending
|
||||
StatusFilter.FAILED -> record.status == "failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun List<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||
distinctBy { it.paymentHash }
|
||||
.let { list ->
|
||||
when (f.direction) {
|
||||
DirectionFilter.ALL -> list
|
||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||
}
|
||||
}
|
||||
.let { list ->
|
||||
if (f.types.isEmpty()) list
|
||||
else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization
|
||||
}
|
||||
.let { list ->
|
||||
if (f.statuses.isEmpty()) list
|
||||
else list.filter { item ->
|
||||
f.statuses.any { status ->
|
||||
when (status) {
|
||||
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||
StatusFilter.PENDING -> item.isPending
|
||||
StatusFilter.FAILED -> item.isFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.let { list ->
|
||||
if (f.searchQuery.isBlank()) list
|
||||
else list.filter { item ->
|
||||
item.memo?.contains(f.searchQuery, ignoreCase = true) == true
|
||||
|| item.contactName?.contains(f.searchQuery, ignoreCase = true) == true
|
||||
|| item.alias?.contains(f.searchQuery, ignoreCase = true) == true
|
||||
|| item.paymentHash.startsWith(f.searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,34 +22,31 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||
import com.bitcointxoko.gudariwallet.util.isFailedStatus
|
||||
import com.bitcointxoko.gudariwallet.util.isPendingStatus
|
||||
|
||||
@Composable
|
||||
internal fun PaymentRow(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
contactName : String? = null,
|
||||
item : PaymentFeedItem,
|
||||
onClick : () -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val isPending = isPendingStatus(payment.status)
|
||||
val isFailed = isFailedStatus(payment.status)
|
||||
val semantic = semanticColors()
|
||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||
else Icons.Default.ArrowDownward
|
||||
|
||||
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||
val amountColor = paymentAmountColor(
|
||||
isPending = item.isPending,
|
||||
isFailed = item.isFailed,
|
||||
isOutgoing = item.isOutgoing
|
||||
)
|
||||
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
|
||||
|
||||
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
|
||||
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
|
||||
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
|
||||
else null
|
||||
}
|
||||
|
||||
@@ -62,8 +59,8 @@ internal fun PaymentRow(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
|
||||
else strings.history.paymentRowReceivedCD, // ← "Received"
|
||||
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
|
||||
else strings.history.paymentRowReceivedCD,
|
||||
tint = amountColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
@@ -71,35 +68,36 @@ internal fun PaymentRow(
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = contactName // ← contact name first
|
||||
?: payment.memo?.takeIf { it.isNotBlank() }
|
||||
?: strings.history.paymentRowNoMemo,
|
||||
text = item.contactName // contact name first
|
||||
?: item.memo?.takeIf { it.isNotBlank() } // then memo
|
||||
?: strings.history.paymentRowNoMemo, // then fallback
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
||||
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||
val formattedTime = remember(item.createdAt ?: item.time) {
|
||||
formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
val statusLabel = when {
|
||||
isPending -> strings.history.paymentRowPending
|
||||
isFailed -> strings.history.paymentRowFailed
|
||||
item.isPending -> strings.history.paymentRowPending
|
||||
item.isFailed -> strings.history.paymentRowFailed
|
||||
else -> null
|
||||
}
|
||||
val statusColor = when {
|
||||
isPending -> semantic.warningContainer
|
||||
isFailed -> semantic.errorContainer
|
||||
else -> semantic.successContainer // fallback, won't be used
|
||||
item.isPending -> semantic.warningContainer
|
||||
item.isFailed -> semantic.errorContainer
|
||||
else -> semantic.successContainer
|
||||
}
|
||||
val statusContentColor = when {
|
||||
isPending -> semantic.onWarningContainer
|
||||
isFailed -> semantic.onErrorContainer
|
||||
item.isPending -> semantic.onWarningContainer
|
||||
item.isFailed -> semantic.onErrorContainer
|
||||
else -> semantic.onSuccessContainer
|
||||
}
|
||||
if (statusLabel != null) {
|
||||
@@ -119,10 +117,25 @@ internal fun PaymentRow(
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
AmountWithFiatColumn(
|
||||
amountSat = payment.amountSat,
|
||||
isOutgoing = payment.isOutgoing,
|
||||
amountSat = item.amountSat,
|
||||
isOutgoing = item.isOutgoing,
|
||||
amountColor = amountColor,
|
||||
fiatLine = fiatLine
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun paymentAmountColor(
|
||||
isPending : Boolean,
|
||||
isFailed : Boolean,
|
||||
isOutgoing : Boolean
|
||||
): androidx.compose.ui.graphics.Color {
|
||||
val semantic = semanticColors()
|
||||
return when {
|
||||
isPending -> semantic.onWarningContainer
|
||||
isFailed -> MaterialTheme.colorScheme.error
|
||||
isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||
else -> semantic.onSuccessContainer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
fun ReceiveScreen(
|
||||
vm : WalletViewModel,
|
||||
nfcVm : NfcViewModel,
|
||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
||||
onNavigateToPaymentDetail : (paymentHash: String) -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val context = LocalContext.current
|
||||
@@ -162,7 +162,7 @@ fun ReceiveScreen(
|
||||
amountSats = state.amountSats,
|
||||
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||
subLine = state.memo?.takeIf { it.isNotBlank() },
|
||||
onDetails = { onNavigateToPaymentDetail(state.checkingId) },
|
||||
onDetails = { onNavigateToPaymentDetail(state.paymentHash) },
|
||||
secondaryLabel = strings.done,
|
||||
onSecondary = { vm.resetReceiveState() },
|
||||
lnurl = null, // sender identity not available
|
||||
|
||||
@@ -36,7 +36,7 @@ class ReceiveViewModel(
|
||||
_receiveState.value = ReceiveState.PaymentReceived(
|
||||
amountSats = event.amountSats,
|
||||
memo = event.memo,
|
||||
checkingId = rs.paymentHash
|
||||
paymentHash = rs.paymentHash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user