13 Commits

36 changed files with 2236 additions and 921 deletions
+16
View File
@@ -22,5 +22,21 @@
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-arm64-v8a-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-arm64-v8a-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
@@ -4,6 +4,7 @@ import android.app.Application
import com.bitcointxoko.gudariwallet.data.db.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
class ContactRepository(app: Application) {
private val dao = AppDatabase.getInstance(app).contactDao()
@@ -16,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(
@@ -103,6 +109,35 @@ class ContactRepository(app: Application) {
)
}
suspend fun updateAddress(
addressId: String,
type: PaymentAddressType,
address: String,
label: String?,
makeDefault: Boolean
) {
val existing = dao.getAddressById(addressId) ?: return
val contactId = existing.contactId
if (makeDefault) dao.clearDefault(contactId, type.name)
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
dao.getById(contactId)?.let {
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
}
}
dao.updateAddress(
addressId = addressId,
type = type.name,
address = address,
label = label,
isDefault = makeDefault
)
}
suspend fun removeAddress(addressId: String) {
dao.deleteAddressById(addressId)
}
@@ -115,6 +150,8 @@ class ContactRepository(app: Application) {
// --- Lookup (for send flow) ---
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress)
@@ -123,15 +160,17 @@ class ContactRepository(app: Application) {
// --- Contact-Tx link ---
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(checkingId)
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(paymentHash)
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
}
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
dao.unlinkPaymentFromContact(checkingId, contactId)
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
dao.unlinkPaymentFromContact(paymentHash, contactId)
}
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
@@ -139,11 +178,23 @@ class ContactRepository(app: Application) {
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames()
.map { list -> list.associate { it.checkingId to it.contactName } }
.map { list ->
list.associate { it.paymentHash to it.contactName }
.also { map ->
Timber.d(
"CONTACT [MAP EMIT ] ${map.size} entries: ${
map.entries.take(5).joinToString { "${it.key.take(8)}${it.value}" }
}"
)
}
}
suspend fun findCheckingIdsByContactName(query: String): List<String> =
dao.findCheckingIdsByContactName(query)
suspend fun findPaymentHashesByContactName(query: String): List<String> =
dao.findPaymentHashesByContactName(query)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
dao.findCheckingIdsByContactIds(contactIds)
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"
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return
val now = System.currentTimeMillis()
runCatching {
dao.insertAll(payments.map { it.toEntity(now) })
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
payments.forEach { payment ->
val e = payment.toEntity(now)
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
}
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
}.onFailure {
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
}
}
/** Persist the first page of payments to the database. */
suspend fun savePayments(payments: List<PaymentRecord>) {
val now = System.currentTimeMillis()
@@ -114,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. */
@@ -130,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
/** Upsert PaymentRecord to database. */
suspend fun upsertPayment(payment: PaymentRecord) {
val entity = payment.toEntity(savedAt = System.currentTimeMillis())
dao.insert(entity)
val e = payment.toEntity(savedAt = System.currentTimeMillis())
Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
}
@@ -166,11 +208,11 @@ class PaymentCacheRepository(context: Context) {
* contact ID filter. Amount/date/status are applied in SQL;
* direction and type are handled in-memory afterwards (same as queryAll).
*/
suspend fun queryByCheckingIds(
checkingIds : List<String>,
suspend fun queryByPaymentHashes(
paymentHashes : List<String>,
filter : PaymentFilter
): List<PaymentRecord> {
if (checkingIds.isEmpty()) return emptyList()
if (paymentHashes.isEmpty()) return emptyList()
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
filter.statuses.flatMap { s ->
when (s) {
@@ -180,8 +222,8 @@ class PaymentCacheRepository(context: Context) {
}
}.joinToString(",")
}
return dao.getByCheckingIdsFiltered(
checkingIds = checkingIds,
return dao.getByPaymentHashesFiltered(
paymentHashes = paymentHashes,
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
minCreatedAt = filter.minCreatedAt,
@@ -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()
}
@@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PaymentAddressEntity::class,
TxContactCrossRef::class,
],
version = 9,
version = 10,
exportSchema = false
)
@TypeConverters(PaymentConverters::class)
@@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() {
}
}
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
override fun migrate(database: SupportSQLiteDatabase) {
// 1. Create replacement table — same schema minus the payment_records FK
database.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""")
// 2. Copy existing rows
database.execSQL("""
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
SELECT checkingId, contactId, role FROM tx_contact_links
""")
// 3. Swap tables
database.execSQL("DROP TABLE tx_contact_links")
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
}
}
fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
@@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9
MIGRATION_8_9,
MIGRATION_9_10
)
.build()
.also { INSTANCE = it }
@@ -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 {
@@ -54,8 +61,24 @@ interface ContactDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAddress(address: PaymentAddressEntity)
@Update
suspend fun updateAddress(address: PaymentAddressEntity)
@Query("""
UPDATE payment_addresses
SET type = :type,
address = :address,
label = :label,
isDefault = :isDefault
WHERE id = :addressId
""")
suspend fun updateAddress(
addressId: String,
type: String,
address: String,
label: String?,
isDefault: Boolean
)
@Query("SELECT * FROM payment_addresses WHERE id = :addressId")
suspend fun getAddressById(addressId: String): PaymentAddressEntity?
@Query("DELETE FROM payment_addresses WHERE id = :id")
suspend fun deleteAddressById(id: String)
@@ -76,51 +99,70 @@ interface ContactDao {
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
// Delete all contact links for a payment
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
suspend fun clearContactsForPayment(checkingId: String)
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
suspend fun clearContactsForPayment(paymentHash: String)
// Get all contacts linked to a payment
@Query("""
SELECT c.* FROM contacts c
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
WHERE tcl.checkingId = :checkingId
WHERE tcl.paymentHash = :paymentHash
""")
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact
@Query("""
SELECT checkingId FROM tx_contact_links
SELECT paymentHash FROM tx_contact_links
WHERE contactId = :contactId
ORDER BY createdAt DESC
""")
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// Returns all (checkingId, displayName) pairs that have a linked contact,
// Returns all (paymentHash, displayName) pairs that have a linked contact,
// so HistoryViewModel can build its lookup map in a single reactive query.
@Query("""
SELECT tcl.checkingId AS checkingId,
SELECT tcl.paymentHash AS paymentHash,
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
""")
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// For text search matching contact names — returns checkingIds of matched payments
// Returns all (paymentHash, contactId, displayName) triples — used by
// PaymentFeedRepository to build full ContactSummary lists per payment.
@Query("""
SELECT tcl.checkingId FROM tx_contact_links tcl
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
INNER JOIN contacts c ON c.id = tcl.contactId
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""")
suspend fun findCheckingIdsByContactName(query: String): List<String>
suspend fun findPaymentHashesByContactName(query: String): List<String>
// For filter-by-contact — returns checkingIds for specific contact IDs
// For filter-by-contact — returns paymentHashs for specific contact IDs
@Query("""
SELECT DISTINCT checkingId FROM tx_contact_links
SELECT DISTINCT paymentHash FROM tx_contact_links
WHERE contactId IN (:contactIds)
""")
suspend fun findCheckingIdsByContactIds(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>
}
@@ -1,6 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName(
val checkingId : String,
val paymentHash : String,
val contactName : String
)
@@ -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 {
@@ -13,10 +15,53 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(payment: PaymentRecordEntity)
// Safe upsert used by mergePayments only.
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
// the server-owned fields in-place — the parent row is never deleted,
// so the TxContactCrossRef CASCADE is never triggered.
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertIgnore(payment: PaymentRecordEntity)
@Query("""
UPDATE payment_records SET
paymentHash = :paymentHash,
amountMsat = :amountMsat,
feeMsat = :feeMsat,
memo = :memo,
time = :time,
createdAt = :createdAt,
status = :status,
bolt11 = :bolt11,
pending = :pending,
preimage = :preimage,
extra = :extra,
savedAt = :savedAt
WHERE checkingId = :checkingId
""")
suspend fun updateSafe(
checkingId: String,
paymentHash: String,
amountMsat: Long,
feeMsat: Long,
memo: String?,
time: String,
createdAt: Long?,
status: String,
bolt11: String?,
pending: Boolean,
preimage: String?,
extra: String?,
savedAt: Long
)
/** Returns all rows ordered by time descending. */
@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>
@@ -38,7 +83,7 @@ interface PaymentDao {
@Query("""
SELECT * FROM payment_records
WHERE checkingId IN (:checkingIds)
WHERE paymentHash IN (:paymentHashes)
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
@@ -47,8 +92,8 @@ interface PaymentDao {
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
ORDER BY createdAt DESC, time DESC
""")
suspend fun getByCheckingIdsFiltered(
checkingIds : List<String>,
suspend fun getByPaymentHashesFiltered(
paymentHashes : List<String>,
minAmountMsat : Long? = null,
maxAmountMsat : Long? = null,
minCreatedAt : Long? = null,
@@ -6,28 +6,10 @@ import androidx.room.Index
@Entity(
tableName = "tx_contact_links",
primaryKeys = ["checkingId", "contactId"],
foreignKeys = [
ForeignKey(
entity = PaymentRecordEntity::class,
parentColumns = ["checkingId"],
childColumns = ["checkingId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("checkingId"),
Index("contactId")
]
primaryKeys = ["paymentHash", "contactId"]
)
data class TxContactCrossRef(
val checkingId : String,
val paymentHash : String,
val contactId : String,
val role : String? = null,
val createdAt : Long = System.currentTimeMillis()
@@ -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 memo : String,
val expiresAt : Long
) : ReceiveState()
data class PaymentReceived(
) : ReceiveState()
data class PaymentReceived(
val amountSats: Long,
val memo : String?,
val checkingId : String
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
val paymentHash : String
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady(
val k1 : String,
val callback : String,
@@ -85,7 +85,8 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String,
override val createdAt : Instant,
override val expiresAt : Instant
override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice
data object Paying : SendState()
data class PaymentSent(
@@ -93,7 +94,9 @@ sealed class SendState {
val feeSats : Long? = null,
val paymentHash: String,
val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null
val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState()
data class Error(val message: String) : SendState()
}
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -72,7 +74,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay
@@ -90,13 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
private const val ROUTE_CONTACTS = "contacts"
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
fun WalletScreen(
vm : WalletViewModel,
@@ -131,10 +134,17 @@ fun WalletScreen(
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
feedRepository = PaymentFeedRepository(
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
nodeAliasRepository = vm.aliasRepo,
fiatVm = vm.fiatVm
),
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
@@ -367,18 +377,23 @@ fun WalletScreen(
ReceiveScreen(
vm = vm,
nfcVm = nfcVm,
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$paymentHash")
}
)
}
composable(TabItem.Send.route) {
SendScreen(
vm = vm,
vm = vm,
nfcVm = nfcVm,
onViewDetails = { checkingId, record ->
onViewDetails = { paymentHash, record ->
historyVm.primePayment(record)
navController.navigate("payment_detail/$checkingId") {
navController.navigate("payment_detail/$paymentHash") {
launchSingleTop = true
}
},
onViewContact = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
@@ -417,34 +432,30 @@ fun WalletScreen(
onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onPaymentClick = { payment ->
navController.navigate("payment_detail/${payment.checkingId}")
onPaymentClick = { item ->
navController.navigate("payment_detail/${item.paymentHash}")
}
)
}
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry ->
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
val payment = remember(historyState, fallbackState, checkingId) {
(historyState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
?: (fallbackState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
val item = remember(feedItems, paymentHash) {
historyVm.findPayment(paymentHash)
}
if (payment != null) {
if (item != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
item = item,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
LaunchedEffect(paymentHash) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -105,7 +105,8 @@ class WalletViewModel(
amountMsat: Long,
comment : String?,
strings : SendStrings,
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings)
contactId : String?
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings, contactId)
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings)
@@ -52,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner,
payer = lnurlPayer,
poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
)
val clipboardVm = ClipboardViewModel(
@@ -0,0 +1,57 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
// ── Contact avatar ───────────────────────────────────────────
private fun String.toHslColor(
saturation: Float = 0.5f,
lightness: Float = 0.4f
): Color {
val hue = fold(0) { acc, char -> char.code + acc * 37 } % 360
return Color.hsl(abs(hue).toFloat(), saturation, lightness)
}
@Composable
fun ContactAvatar(
name: String,
modifier: Modifier = Modifier,
size: Dp = 40.dp
) {
val bgColor = remember(name) {
name.toHslColor()
}
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = modifier
.size(size)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(SolidColor(bgColor))
}
Text(
text = initial,
color = Color.White,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
}
}
@@ -3,6 +3,7 @@ package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -11,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
@@ -40,11 +42,16 @@ fun PaymentSuccessContent(
onSecondary : () -> Unit,
lnurl : String? = null,
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
onViewContact : ((contactId: String) -> Unit)? = null,
contactId : String? = null,
contactName : String? = null
) {
var showSaveSheet by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@@ -66,21 +73,40 @@ fun PaymentSuccessContent(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(32.dp))
// ── Save contact affordance (send-side only) ─────────────────────────
if (lnurl != null && onSaveContact != null) {
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = { showSaveSheet = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
when {
contactName != null -> {
// Contact already known — show their name
val canNavigate = contactId != null && onViewContact != null
AssistChip(
onClick = { if (canNavigate) onViewContact!!(contactId!!) },
label = { Text(contactName) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(AssistChipDefaults.IconSize)
)
},
enabled = canNavigate
)
Spacer(Modifier.width(8.dp))
Text("Save contact")
}
lnurl != null && onSaveContact != null -> {
// Unknown sender — offer to save
OutlinedButton(
onClick = { showSaveSheet = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Save contact")
}
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -49,6 +48,7 @@ fun ContactDetailScreen(
var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
Scaffold(
topBar = {
@@ -131,8 +131,9 @@ fun ContactDetailScreen(
onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() },
onEditAddress = { addr -> editingAddress = addr },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding)
modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
)
}
}
@@ -189,6 +190,18 @@ fun ContactDetailScreen(
)
}
// ── Edit address sheet ──────────────────────────────────────────
editingAddress?.let { addr ->
EditAddressSheet(
address = addr,
onSave = { id, type, addressValue, label, makeDefault ->
viewModel.updateAddress(id, type, addressValue, label, makeDefault)
editingAddress = null
},
onDismiss = { editingAddress = null }
)
}
// ── Edit alias sheet ──────────────────────────────────────────────────
if (showEditAliasSheet) {
val currentAlias = (uiState as? ContactDetailUiState.Success)
@@ -214,6 +227,7 @@ private fun ContactDetailContent(
onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit,
onEditAddress: (PaymentAddressEntity) -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -282,7 +296,8 @@ private fun ContactDetailContent(
isEditing = isEditing,
onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) }
onSetDefault = { onSetDefault(addr) },
onEdit = { onEditAddress(addr) }
)
if (index < addresses.lastIndex) {
HorizontalDivider(
@@ -357,6 +372,7 @@ private fun AddressRow(
onPay: () -> Unit,
onRemove: () -> Unit,
onSetDefault: () -> Unit,
onEdit: () -> Unit
) {
val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
@@ -380,6 +396,13 @@ private fun AddressRow(
) {
// ── Label + value ─────────────────────────────────────────────
Column(modifier = Modifier.weight(1f)) {
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -409,12 +432,6 @@ private fun AddressRow(
)
}
}
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// ── Trailing actions ──────────────────────────────────────────
@@ -428,17 +445,14 @@ private fun AddressRow(
// Star + delete only in edit mode
if (isEditing) {
IconButton(
onClick = onSetDefault,
onClick = onEdit,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = if (address.isDefault) Icons.Filled.Star
else Icons.Outlined.Star,
contentDescription = if (address.isDefault) "Default address"
else "Set as default",
imageVector = Icons.Default.Edit,
contentDescription = "Edit address",
modifier = Modifier.size(18.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
@@ -458,8 +472,6 @@ private fun AddressRow(
}
// ── Add address sheet ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@@ -585,6 +597,136 @@ private fun AddAddressSheet(
}
}
// ── Edit address sheet ────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditAddressSheet(
address: PaymentAddressEntity,
onSave: (id: String, type: PaymentAddressType, addressValue: String, label: String?, makeDefault: Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val addressFocus = remember { FocusRequester() }
var selectedType by remember {
mutableStateOf(PaymentAddressType.entries.find { it.name == address.type }
?: PaymentAddressType.LIGHTNING_ADDRESS)
}
var addressValue by remember { mutableStateOf(address.address) }
var label by remember { mutableStateOf(address.label ?: "") }
var makeDefault by remember { mutableStateOf(address.isDefault) }
val canSave = addressValue.isNotBlank()
LaunchedEffect(Unit) { addressFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Edit payment address", style = MaterialTheme.typography.titleMedium)
// Type chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS },
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = { selectedType = PaymentAddressType.LNURL },
label = { Text("LNURL") }
)
}
// Address field
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"Lightning Address" else "LNURL"
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"user@domain.com" else "LNURL1..."
OutlinedTextField(
value = addressValue,
onValueChange = { addressValue = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(addressFocus)
)
// Optional label
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
placeholder = { Text("e.g. personal, savings") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
}
),
modifier = Modifier.fillMaxWidth()
)
// Set as default toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Set as default",
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = makeDefault,
onCheckedChange = { makeDefault = it }
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = {
focusManager.clearFocus()
onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
},
enabled = canSave
) { Text("Save") }
}
}
}
}
// ── Edit alias sheet ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@@ -68,6 +68,19 @@ class ContactDetailViewModel(
}
}
fun updateAddress(
addressId: String,
type: PaymentAddressType,
address: String,
label: String?,
makeDefault: Boolean
) {
viewModelScope.launch {
repo.updateAddress(addressId, type, address, label, makeDefault)
}
}
fun removeAddress(addressId: String) {
viewModelScope.launch { repo.removeAddress(addressId) }
}
@@ -7,17 +7,26 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -29,6 +38,8 @@ fun ContactsScreen(
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCreateSheet by remember { mutableStateOf(false) }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
@@ -41,6 +52,17 @@ fun ContactsScreen(
contentDescription = strings.back
)
}
},
actions = {
IconButton(onClick = {
searchVisible = !searchVisible
if (!searchVisible) searchQuery = ""
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search contacts"
)
}
}
)
},
@@ -50,58 +72,96 @@ fun ContactsScreen(
}
}
) { innerPadding ->
when (val state = uiState) {
is ContactsUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
val focusRequester = remember { FocusRequester() }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
LaunchedEffect(searchVisible) {
if (searchVisible) {
// kotlinx.coroutines.delay(50)
focusRequester.requestFocus()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
if (searchVisible) {
ContactsSearchBar(
query = searchQuery,
focusRequester = focusRequester,
clipboard = clipboard,
scope = scope,
onQueryChange = { searchQuery = it },
onClear = { searchQuery = "" }
)
HorizontalDivider()
}
is ContactsUiState.Success -> {
if (state.contacts.isEmpty()) {
ContactsEmptyContent(
onAdd = { showCreateSheet = true },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(vertical = 8.dp)
when (val state = uiState) {
is ContactsUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
items(state.contacts, key = { it.contact.id }) { item ->
ContactRow(item, onClick = { onContactClick(item.contact.id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
CircularProgressIndicator()
}
}
is ContactsUiState.Success -> {
val filteredContacts = remember(state.contacts, searchQuery) {
if (searchQuery.isBlank()) state.contacts
else state.contacts.filter { item ->
val contact = item.contact
val name = contact.localAlias
?: contact.displayName
?: contact.name
?: ""
val addressMatch = item.addresses.any { addr ->
addr.address.contains(searchQuery, ignoreCase = true)
}
name.contains(searchQuery, ignoreCase = true) || addressMatch
}
}
if (filteredContacts.isEmpty()) {
ContactsEmptyContent(
onAdd = { showCreateSheet = true },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(filteredContacts, key = { it.contact.id }) { item ->
ContactRow(item, onClick = { onContactClick(item.contact.id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
}
}
}
}
}
if (showCreateSheet) {
CreateContactSheet(
onSave = { name, addressType, address ->
viewModel.createContact(name, addressType, address)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
if (showCreateSheet) {
CreateContactSheet(
onSave = { name, addressType, address ->
viewModel.createContact(name, addressType, address)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
}
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
// ── Empty state ──────────────────────────────────────────────────────────────
@Composable
private fun ContactsEmptyContent(
@@ -140,7 +200,65 @@ private fun ContactsEmptyContent(
}
}
// ── Contact row ────────────────────────────────────────────────────────────────
// ── Search bar ───────────────────────────────────────────────────────────────
@Composable
private fun ContactsSearchBar(
query: String,
focusRequester: FocusRequester,
clipboard: androidx.compose.ui.platform.Clipboard,
scope: CoroutineScope,
onQueryChange: (String) -> Unit,
onClear: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search contacts") },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
)
IconButton(onClick = {
scope.launch {
val pasted = clipboard.getClipEntry()
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
onQueryChange(pasted)
}
}) {
Icon(
imageVector = Icons.Default.ContentPaste,
contentDescription = "Paste"
)
}
IconButton(
onClick = onClear,
enabled = query.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
// ── Contact row ──────────────────────────────────────────────────────────────
@Composable
private fun ContactRow(
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -50,6 +51,7 @@ class ContactsViewModel(
}
}
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
@@ -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,46 +56,47 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.HistoryState
import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch
// ── List screen ──────────────────────────────────────────────────────────────
// ── List screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
vm : HistoryViewModel,
onClose : () -> Unit,
onPaymentClick : (PaymentRecord) -> Unit
onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
) {
val strings = LocalAppStrings.current
val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val strings = LocalAppStrings.current
val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
val filter by vm.filter.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val listState = rememberLazyListState()
val contactNames by vm.contactNames.collectAsState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
// they are now embedded in each PaymentFeedItem
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchInfoSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.isActive
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.history.historyTitle) }, // ← "History"
title = { Text(strings.history.historyTitle) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.historyClose // ← "Close"
contentDescription = strings.history.historyClose
)
}
},
@@ -109,7 +110,7 @@ fun HistoryScreen(
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = strings.history.historySearchPayments // ← "Search payments"
contentDescription = strings.history.historySearchPayments
)
}
}
@@ -119,7 +120,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = strings.history.historyFilterPayments // ← "Filter payments"
contentDescription = strings.history.historyFilterPayments
)
}
}
@@ -140,7 +141,7 @@ fun HistoryScreen(
.fillMaxSize()
.padding(padding)
) {
// ── Search bar ───────────────────────────────────────────────────
// ── Search bar ─────────────────────────────────────────────────
if (searchVisible) {
SearchBar(
query = filter.searchQuery,
@@ -154,7 +155,7 @@ fun HistoryScreen(
HorizontalDivider()
}
// ── Active filter chips ──────────────────────────────────────────
// ── Active filter chips ────────────────────────────────────────
val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) {
ActiveFilterChips(
@@ -172,14 +173,84 @@ fun HistoryScreen(
)
}
// ── Content ──────────────────────────────────────────────────────
// ── Content ────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
// ── List — always rendered when state is Success, never unmounted ──────
val s = state
if (s is HistoryState.Success) {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (items.isEmpty()) {
Text(
text = strings.history.historyNoPayments,
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = items,
key = { it.paymentHash },
contentType = { "payment" }
) { item ->
PaymentRow(
item = item,
onClick = { onPaymentClick(item) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item(contentType = "loader") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
}
}
}
// ── State overlays — sit on top of the list, never destroy it ─────────
when (val s = state) {
is HistoryState.Loading -> {
// Only show the full-screen spinner on the very first load
// (when we have no items yet). During loadMore(), the in-list
// footer spinner handles it — don't obscure the results.
if (items.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
is HistoryState.Error -> {
Column(
modifier = Modifier.align(Alignment.Center),
@@ -192,96 +263,33 @@ fun HistoryScreen(
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) {
Text(strings.history.historyRetry) // ← "Retry"
}
}
}
is HistoryState.Success -> {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
Text(
text = strings.history.historyNoPayments, // ← "No payments yet."
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = s.payments,
key = { it.paymentHash },
contentType = { "payment" }
) { payment ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.checkingId],
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item(contentType = "loader") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
Text(strings.history.historyRetry)
}
}
}
is HistoryState.Success -> { /* handled above */ }
}
}
}
}
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
if (filterSheetVisible) {
FilterBottomSheet(
currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
vm.setDirectionFilter(direction)
dismissFilterSheet()
},
onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
onDatePresetSelected = { vm.applyDatePreset(it) },
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
onDateClearRequested = { vm.clearDateFilter() }
)
}
@@ -291,7 +299,7 @@ fun HistoryScreen(
)
}
// ── Search bar ───────────────────────────────────────────────────────────────
// ── Search bar ───────────────────────────────────────────────────────────────
@Composable
private fun SearchBar(
query : String,
@@ -312,13 +320,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = strings.history.historySearchHelp // ← "Search help"
contentDescription = strings.history.historySearchHelp
)
}
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
placeholder = { Text(strings.history.historySearchPlaceholder) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -339,7 +347,7 @@ private fun SearchBar(
}) {
Icon(
imageVector = Icons.Default.ContentPaste,
contentDescription = strings.history.historySearchPaste // ← "Paste"
contentDescription = strings.history.historySearchPaste
)
}
IconButton(
@@ -348,7 +356,7 @@ private fun SearchBar(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.historySearchClear, // ← "Clear search"
contentDescription = strings.history.historySearchClear,
tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
@@ -356,22 +364,22 @@ private fun SearchBar(
}
}
// ── Active filter chips ──────────────────────────────────────────────────────
// ── Active filter chips ──────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ActiveFilterChips(
filter : PaymentFilter,
searchActive : Boolean,
onClearDirection: () -> Unit,
onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit,
onClearAmount : () -> Unit,
onClearDate : () -> Unit,
onClearSearch : () -> Unit
filter : PaymentFilter,
searchActive : Boolean,
onClearDirection : () -> Unit,
onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit,
onClearAmount : () -> Unit,
onClearDate : () -> Unit,
onClearSearch : () -> Unit
) {
val strings = LocalAppStrings.current
FlowRow(
modifier = Modifier
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -384,15 +392,15 @@ private fun ActiveFilterChips(
label = {
Text(
when (filter.direction) {
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
else -> strings.history.filterDirectionIncoming // ← "Incoming"
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
else -> strings.history.filterDirectionIncoming
}
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
contentDescription = strings.history.filterClearDirection,
modifier = Modifier.size(16.dp)
)
}
@@ -405,16 +413,16 @@ private fun ActiveFilterChips(
label = {
Text(
when (s) {
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
StatusFilter.PENDING -> strings.history.filterStatusPending
StatusFilter.FAILED -> strings.history.filterStatusFailed
}
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
contentDescription = strings.history.filterRemoveStatus,
modifier = Modifier.size(16.dp)
)
}
@@ -428,7 +436,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.filterRemoveType, // ← "Remove type filter"
contentDescription = strings.history.filterRemoveType,
modifier = Modifier.size(16.dp)
)
}
@@ -437,11 +445,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null ->
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats"
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
filter.minAmountSat != null ->
strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
strings.history.filterAmountMin(filter.minAmountSat)
else ->
strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
strings.history.filterAmountMax(filter.maxAmountSat!!)
}
InputChip(
selected = true,
@@ -450,7 +458,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter"
contentDescription = strings.history.filterClearAmount,
modifier = Modifier.size(16.dp)
)
}
@@ -460,19 +468,23 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
null -> when {
minCreatedAt != null && maxCreatedAt != null ->
strings.history.filterDateRange( // ← "XY"
strings.history.filterDateRange(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
)
minCreatedAt != null ->
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
strings.history.filterDateFrom(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
)
else ->
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
strings.history.filterDateUntil(
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
)
}
}
InputChip(
@@ -482,7 +494,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearDate, // ← "Clear date filter"
contentDescription = strings.history.filterClearDate,
modifier = Modifier.size(16.dp)
)
}
@@ -492,11 +504,11 @@ private fun ActiveFilterChips(
InputChip(
selected = true,
onClick = onClearSearch,
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.history.historyClearSearch, // ← "Clear search"
contentDescription = strings.history.historyClearSearch,
modifier = Modifier.size(16.dp)
)
}
@@ -8,166 +8,265 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel"
@OptIn(FlowPreview::class)
class HistoryViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
private val repo : WalletRepository,
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,
repo = repo,
paymentCache = paymentCache,
scope = viewModelScope,
onNewPayments = { payments -> enricher.enrich(payments) }
)
// ── Optimistic insert (called after a successful send) ─────────────────────
fun primePayment(record: PaymentRecord) {
syncManager.primePayment(record)
}
// ── Enricher ──────────────────────────────────────────────────────────────
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
// which feeds into the feedRepository.observeFeedItems() combine.
private val enricher: PaymentEnricher = PaymentEnricher(
repo = repo,
aliasRepo = aliasRepo,
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
scope = viewModelScope,
onEnriched = { paymentHash, pubkey, alias ->
scope = viewModelScope,
onEnriched = { paymentHash, pubkey, alias ->
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
}
)
// ── Sync status ───────────────────────────────────────────────────────────
// Exposes Loading / Error / Success shell (isRefreshing, canLoadMore).
// Does NOT carry payment data — see feedItems below.
val state: StateFlow<HistoryState> = syncManager.state
fun refresh() = syncManager.refresh()
fun refresh() = syncManager.refresh()
fun loadMore() = syncManager.loadMore(
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true }
filteredCanLoadMore = {
(state.value as? HistoryState.Success)?.canLoadMore == true
}
)
// ── Detail delegate ───────────────────────────────────────────────────────
// ── Detail delegate ───────────────────────────────────────────────────────
private val detailDelegate = PaymentDetailDelegate(
repo = repo,
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
scope = viewModelScope
scope = viewModelScope
)
val detailState: StateFlow<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) { s, f, roomResults ->
if (s !is HistoryState.Success) return@combine s
if (roomResults == null) {
s.copy(payments = s.payments.applyInMemoryFilters(f))
} else {
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
}
}.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) {
DirectionFilter.ALL -> list
DirectionFilter.ALL -> list
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
}
}
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { item ->
f.statuses.any { status ->
when (status) {
StatusFilter.PENDING -> item.isPending
StatusFilter.FAILED -> item.isFailed
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
}
}
}
}
.let { list ->
if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types }
else list.filter { it.tag in f.types }
}
.let { list ->
val min = f.minAmountSat;
val max = f.maxAmountSat
if (min == null && max == null) list
else list.filter { item ->
(min == null || item.amountSat >= min) &&
(max == null || item.amountSat <= max)
}
}
.let { list ->
val min = f.minCreatedAt;
val max = f.maxCreatedAt
if (min == null && max == null) list
else list.filter { item ->
val ts = item.createdAt ?: item.time
ts != null &&
(min == null || ts >= min) &&
(max == null || ts <= max)
}
}
.let { list ->
val q = f.searchQuery
if (q.isBlank()) list
else list.filter { item ->
item.memo?.contains(q, ignoreCase = true) == true ||
item.contactName?.contains(q, ignoreCase = true) == true ||
item.paymentHash.contains(q, ignoreCase = true)
}
}
// ── Contact name map ─────────────────────────────────────────────────────
// Maps checkingId → display name for any payment that has a linked contact.
// Rebuilt whenever the underlying tx_contact_links or contacts tables change.
val contactNames: StateFlow<Map<String, String>> =
contactRepo.observeAllPaymentContactNames()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
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 ->
@@ -184,8 +283,7 @@ class HistoryViewModel(
initialValue = emptyMap()
)
// ── Contact assignment ───────────────────────────────────────────────────
// ── Per-payment contact queries ────────────────────────────────────────────
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
contactRepo.observeContactsForPayment(paymentHash)
.stateIn(
@@ -202,137 +300,169 @@ class HistoryViewModel(
initialValue = emptyList()
)
// ── Contact assignment ─────────────────────────────────────────────────────
fun assignContact(paymentHash: String, contactId: String) {
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.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
val contact = contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
lnAddress = lnAddress,
lnUrl = lnUrl
)
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
contactRepo.linkPaymentToContact(paymentHash, contact.id)
}
}
fun toggleContactFilter(contactId: String) {
_filter.update { f ->
val updated = if (contactId in f.contactIds)
f.contactIds - contactId else f.contactIds + contactId
f.copy(contactIds = updated)
}
}
fun clearContactFilter() {
_filter.update { it.copy(contactIds = emptySet()) }
}
// ── Init ──────────────────────────────────────────────────────────────────
// ── Init ───────────────────────────────────────────────────────────────────
init {
syncManager.init()
// ── Room query path ────────────────────────────────────────────────────
// Activated when the filter requires DB-level text search or contact-ID
// lookup. Results are stored in _roomResults and override the live feed
// stream in feedItems. Cleared back to null when no Room query is needed.
viewModelScope.launch {
combine(
_filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter
) { _, f -> f }
.distinctUntilChanged()
.collect { f ->
if (!f.needsRoomQuery()) {
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
_roomResults.value = null
} else {
// ── Room query path ───────────────────────────────────
val baseResults = paymentCache.queryAll(f.searchQuery, f)
.toMutableList()
return@collect
}
// ── Supplemental contact search ───────────────────────
// 1. Text query may match contact names not in payment_records
if (f.searchQuery.isNotBlank()) {
val contactCheckingIds = contactRepo
.findCheckingIdsByContactName(f.searchQuery)
if (contactCheckingIds.isNotEmpty()) {
val contactPayments = paymentCache
.queryByCheckingIds(contactCheckingIds, f)
// Merge without duplicates
val existingIds = baseResults.map { it.checkingId }.toSet()
baseResults += contactPayments
.filter { it.checkingId !in existingIds }
}
// Snapshot the contact summaries once per query — no new DAO needed.
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
// resolve contact names without per-row DB calls.
val contactSnapshot: Map<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
}
// 2. Explicit contact ID filter
if (f.contactIds.isNotEmpty()) {
val contactCheckingIds = contactRepo
.findCheckingIdsByContactIds(f.contactIds.toList())
if (contactCheckingIds.isNotEmpty()) {
// Fetch ALL payments for these contacts, WITH the full filter applied
val contactFiltered = paymentCache.queryByCheckingIds(
checkingIds = contactCheckingIds,
filter = f
)
_roomResults.value = contactFiltered
.also {
Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds}${it.size} results")
}
return@collect
} else {
// Selected contacts have no payments — return empty
_roomResults.value = emptyList()
return@collect
}
}
// ── Text search ────────────────────────────────────────────
val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList()
_roomResults.value = baseResults.also {
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\"${it.size} results")
if (f.searchQuery.isNotBlank()) {
val contactHashes = contactRepo
.findPaymentHashesByContactName(f.searchQuery)
if (contactHashes.isNotEmpty()) {
val existingIds = baseRecords.map { it.checkingId }.toSet()
baseRecords += paymentCache
.queryByPaymentHashes(contactHashes, f)
.filter { it.checkingId !in existingIds }
}
}
_roomResults.value = baseRecords
.map { it.toFeedItem(contactSnapshot) }
.also {
Timber.tag(TAG).d(
"FILTER [ROOM] q=\"${f.searchQuery}\"${it.size} results"
)
}
}
}
// ── WebSocket payment event listener ───────────────────────────────────
viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event ->
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
syncManager.refresh()
}
}
}
// ── Private helper ─────────────────────────────────────────────────────────
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
// Contact links, alias, and fiat are intentionally left empty/null here —
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
// for all items that pass through the live feed path.
private fun PaymentRecord.toFeedItem(
contactSnapshot: Map<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 repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit: StateFlow<Double?>
private val feedRepository : PaymentFeedRepository,
private val fiatCurrency : StateFlow<String?>,
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
}
}
}
@@ -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 }
}
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -53,8 +54,11 @@ 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
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@@ -66,41 +70,57 @@ import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch
// ── Detail screen ─────────────────────────────────────────────────────────────
// ── Detail screen ─────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentDetailScreen(
payment : PaymentRecord,
item : PaymentFeedItem, // ← was: payment: PaymentRecord
vm : HistoryViewModel,
onBack : () -> Unit
) {
val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState()
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
// fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
// no need to collect them from the ViewModel separately.
val fiatCurrency = item.fiatCurrency
val fiatSatsPerUnit = item.fiatSatsPerUnit
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
// Enrich the base PaymentFeedItem's backing record with the detail response.
// PaymentDetailContent still operates on PaymentRecord for technical fields.
// enriched falls back through partial → base PaymentRecord via toDomain()
// if the detail fetch is still in flight or has failed.
val enriched: PaymentRecord = when (val s = detailState) {
is DetailState.Success -> {
val detail = s.detail.details
when {
detail == null -> payment
detail == null -> item.toBaseRecord()
else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
)
}
}
is DetailState.Partial -> s.record
else -> payment
is DetailState.Partial -> s.record.let { r ->
r.copy(
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
)
}
else -> item.toBaseRecord()
}
// ── Contact state ─────────────────────────────────────────────────────────
val linkedContacts by remember(payment.checkingId) {
vm.contactsForPayment(payment.checkingId)
// ── Contact state ──────────────────────────────────────────────────────────
val linkedContacts by remember(item.paymentHash) {
vm.contactsForPayment(item.paymentHash)
}.collectAsStateWithLifecycle()
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
@@ -108,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(" ") }
@@ -120,7 +139,7 @@ fun PaymentDetailScreen(
TopAppBar(
title = {
Text(
if (payment.isOutgoing) strings.history.detailOutgoingTitle
if (item.isOutgoing) strings.history.detailOutgoingTitle
else strings.history.detailIncomingTitle
)
},
@@ -138,8 +157,8 @@ fun PaymentDetailScreen(
when (detailState) {
is DetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
@@ -163,7 +182,7 @@ fun PaymentDetailScreen(
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(payment.checkingId, contactId)
vm.unassignContact(item.paymentHash, contactId)
}
)
}
@@ -177,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)
)
@@ -185,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 = {
@@ -201,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
@@ -209,8 +228,8 @@ fun PaymentDetailScreen(
CreateContactSheet(
initialAddressType = addressType,
initialAddress = paymentLnAddress,
onSave = { name, type, address ->
vm.saveContactAndAssign(payment.checkingId, name, type, address)
onSave = { name, type, address ->
vm.saveContactAndAssign(item.paymentHash, name, type, address)
showCreateContact = false
},
onDismiss = { showCreateContact = false }
@@ -219,19 +238,26 @@ fun PaymentDetailScreen(
}
// ── Detail content ─────────────────────────────────────────────────────────────
// Receives the enriched PaymentRecord from detailState — all technical fields
// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here.
// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item).
@Composable
private fun PaymentDetailContent(
payment : PaymentRecord,
fiatCurrency : String?,
fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
payment : PaymentRecord,
fiatCurrency : String?,
fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(
isPending = payment.pending,
isFailed = payment.status == "failed",
isOutgoing = payment.isOutgoing
)
val context = LocalContext.current
val clipboard = LocalClipboard.current
@@ -252,8 +278,8 @@ private fun PaymentDetailContent(
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
@@ -261,8 +287,8 @@ private fun PaymentDetailContent(
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
@@ -273,11 +299,11 @@ private fun PaymentDetailContent(
)
Spacer(Modifier.height(8.dp))
AmountWithFiatColumn(
amountSat = payment.amountSat,
isOutgoing = payment.isOutgoing,
amountColor = amountColor,
fiatLine = heroFiat,
style = MaterialTheme.typography.headlineLarge,
amountSat = payment.amountSat,
isOutgoing = payment.isOutgoing,
amountColor = amountColor,
fiatLine = heroFiat,
style = MaterialTheme.typography.headlineLarge,
horizontalAlignment = Alignment.CenterHorizontally
)
if (payment.feeSat > 0) {
@@ -303,70 +329,85 @@ private fun PaymentDetailContent(
// ── Contact section ────────────────────────────────────────────────────
item {
DetailSection(title = "Contact") {
if (linkedContacts.isEmpty()) {
// No contact linked — show assign button
OutlinedButton(
onClick = onAssignContact,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Assign contact")
}
} else {
// Show each linked contact with a remove button
linkedContacts.forEach { contact ->
val name = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
DetailRow(
label = "Contact",
value = name,
trailingContent = {
if (linkedContacts.isEmpty()) {
OutlinedButton(
onClick = onAssignContact,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Assign contact")
// Text(strings.history.detailAssignContact)
}
} else {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Contacts",
// text = strings.history.detailContactsTitle,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(
onClick = onAssignContact,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
// contentDescription = strings.history.detailEditContacts,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(8.dp))
linkedContacts.forEachIndexed { index, contact ->
val name = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
ContactAvatar(name = name)
Spacer(Modifier.width(12.dp))
Text(
text = name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { onUnassignContact(contact.id) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove contact",
contentDescription = "Remove",
// contentDescription = strings.history.detailRemoveContact,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
// Allow assigning additional contacts
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.End
) {
androidx.compose.material3.TextButton(onClick = onAssignContact) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Add another",
style = MaterialTheme.typography.labelMedium
)
if (index < linkedContacts.lastIndex) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
@@ -397,8 +438,8 @@ private fun PaymentDetailContent(
payment.extra?.successAction?.let { action ->
item {
DetailSection(title = strings.history.detailSectionSuccessAction) {
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
}
}
@@ -408,11 +449,11 @@ private fun PaymentDetailContent(
item {
DetailSection(title = strings.history.detailSectionTechnical) {
DetailRow(
label = strings.history.detailRowPaymentHash,
value = payment.paymentHash,
label = strings.history.detailRowPaymentHash,
value = payment.paymentHash,
monospace = true,
copyable = true,
onCopy = {
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
@@ -422,11 +463,11 @@ private fun PaymentDetailContent(
)
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow(
label = strings.history.detailRowPreimage,
value = payment.preimage,
label = strings.history.detailRowPreimage,
value = payment.preimage,
monospace = true,
copyable = true,
onCopy = {
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
@@ -439,11 +480,11 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow(
label = strings.history.detailRowDestination,
value = label,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = {
label = strings.history.detailRowDestination,
value = label,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
},
trailingContent = {
@@ -453,12 +494,12 @@ private fun PaymentDetailContent(
}
if (!payment.bolt11.isNullOrBlank()) {
DetailRow(
label = strings.history.detailRowBolt11,
value = payment.bolt11,
label = strings.history.detailRowBolt11,
value = payment.bolt11,
monospace = true,
copyable = true,
maxLines = 3,
onCopy = {
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
@@ -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,
onClick : () -> Unit
item : PaymentFeedItem,
onClick : () -> Unit
) {
val strings = LocalAppStrings.current
val isPending = isPendingStatus(payment.status)
val isFailed = isFailedStatus(payment.status)
val semantic = semanticColors()
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward
val strings = LocalAppStrings.current
val semantic = semanticColors()
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
val amountColor = paymentAmountColor(
isPending = item.isPending,
isFailed = item.isFailed,
isOutgoing = item.isOutgoing
)
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
else null
}
@@ -61,46 +58,47 @@ internal fun PaymentRow(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
else strings.history.paymentRowReceivedCD, // ← "Received"
tint = amountColor,
modifier = Modifier.size(20.dp)
imageVector = icon,
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
else strings.history.paymentRowReceivedCD,
tint = amountColor,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = contactName // contact name first
?: payment.memo?.takeIf { it.isNotBlank() }
?: strings.history.paymentRowNoMemo,
text = item.contactName // contact name first
?: item.memo?.takeIf { it.isNotBlank() } // then memo
?: strings.history.paymentRowNoMemo, // then fallback
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
val formattedTime = remember(item.createdAt ?: item.time) {
formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
}
Text(
text = formattedTime,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val statusLabel = when {
isPending -> strings.history.paymentRowPending
isFailed -> strings.history.paymentRowFailed
else -> null
item.isPending -> strings.history.paymentRowPending
item.isFailed -> strings.history.paymentRowFailed
else -> null
}
val statusColor = when {
isPending -> semantic.warningContainer
isFailed -> semantic.errorContainer
else -> semantic.successContainer // fallback, won't be used
item.isPending -> semantic.warningContainer
item.isFailed -> semantic.errorContainer
else -> semantic.successContainer
}
val statusContentColor = when {
isPending -> semantic.onWarningContainer
isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer
item.isPending -> semantic.onWarningContainer
item.isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer
}
if (statusLabel != null) {
Spacer(Modifier.height(4.dp))
@@ -119,10 +117,25 @@ internal fun PaymentRow(
}
Spacer(Modifier.width(12.dp))
AmountWithFiatColumn(
amountSat = payment.amountSat,
isOutgoing = payment.isOutgoing,
amountSat = item.amountSat,
isOutgoing = item.isOutgoing,
amountColor = amountColor,
fiatLine = fiatLine
)
}
}
@Composable
internal fun paymentAmountColor(
isPending : Boolean,
isFailed : Boolean,
isOutgoing : Boolean
): androidx.compose.ui.graphics.Color {
val semantic = semanticColors()
return when {
isPending -> semantic.onWarningContainer
isFailed -> MaterialTheme.colorScheme.error
isOutgoing -> MaterialTheme.colorScheme.onSurface
else -> semantic.onSuccessContainer
}
}
@@ -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
)
}
}
@@ -1,15 +1,20 @@
package com.bitcointxoko.gudariwallet.ui.send
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
@@ -20,13 +25,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -37,6 +47,8 @@ import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
private enum class AmountUnit { SATS, FIAT }
@Composable
internal fun LnurlPayForm(
state : SendState.LnurlReady,
@@ -47,13 +59,34 @@ internal fun LnurlPayForm(
) {
val strings = LocalAppStrings.current
var amount by remember { mutableStateOf(state.minSats.toString()) }
var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats
var amountText by remember { mutableStateOf(state.minSats.toString()) }
var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats
val sats: Long? = amount.toLongOrNull()
val showFiatToggle = fiatRate != null && fiatCurrency != null
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
// ── Always compute sats from whatever the user typed ─────────────────
val parsedInput = amountText.trim().toDoubleOrNull()
val sats: Long? = when {
parsedInput == null -> null
activeUnit == AmountUnit.SATS -> parsedInput.toLong()
else /* FIAT */ -> (parsedInput * (fiatRate ?: 1.0)).toLong()
}
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
// ── Button labels — always sats + fiat, regardless of active unit ────
val satsLabelForButton: String = sats?.toString() ?: ""
val fiatLabelForButton: String? = if (sats != null) fiatLabel(sats, fiatRate, fiatCurrency) else null
// ── Conversion label (the opposite unit) for supporting text ─────────
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
if (!showFiatToggle || parsedInput == null) return@remember null
when (activeUnit) {
AmountUnit.SATS -> fiatLabel(parsedInput.toLong(), fiatRate, fiatCurrency)
AmountUnit.FIAT -> "${(parsedInput * (fiatRate ?: 1.0)).toLong()} sats"
}
}
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
@@ -67,10 +100,14 @@ internal fun LnurlPayForm(
}
}
// ── The supporting-text height the picker must not center against ─────
// OutlinedTextFieldDefaults counts: input area ~56 dp, supporting
// text ~16 dp with 4 dp internal padding ≈ 20 dp extra below.
val supportingTextHeight = 20.dp
Column {
// ── Recipient header ──────────────────────────────────────────────
if (existingContact != null) {
// Known contact — show their name instead of the raw address
val contactName = existingContact!!.localAlias
?: existingContact!!.displayName
?: existingContact!!.name
@@ -80,7 +117,6 @@ internal fun LnurlPayForm(
style = MaterialTheme.typography.titleMedium
)
} else {
// Unknown — show domain/address as before
Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
style = MaterialTheme.typography.titleMedium
@@ -108,45 +144,106 @@ internal fun LnurlPayForm(
)
}
Spacer(Modifier.height(12.dp))
// ── Headroom above amount row — the picker has a 32 dp ghost item
// above the selected item; this offsets so the visual centre of
// the picker lines up with the title text above rather than the
// Row's top edge.
Spacer(Modifier.height(16.dp))
// ── Amount field — unchanged ──────────────────────────────────────
OutlinedTextField(
value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.receive.amountInSats) },
isError = amount.isNotBlank() && !isAmountValid,
supportingText = {
when {
amount.isNotBlank() && !isAmountValid -> {
Text(
text = strings.lnurlAmountError(state.minSats, state.maxSats),
color = MaterialTheme.colorScheme.error
)
}
fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
}
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
else -> {}
}
},
enabled = !isFixed,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
// ── Comment field — unchanged ─────────────────────────────────────
if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp))
// ── Amount field with currency switcher ───────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
label = { Text(strings.commentOptional(state.commentAllowed)) },
value = amountText,
onValueChange = {
val allowed = if (activeUnit == AmountUnit.SATS)
it.all(Char::isDigit)
else
it.all { c -> c.isDigit() || c == '.' } &&
it.count { c -> c == '.' } <= 1
if (allowed) amountText = it
},
placeholder = {
Text(
if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
fontFamily = FontFamily.Monospace
)
},
isError = amountText.isNotBlank() && !isAmountValid,
supportingText = {
when {
amountText.isNotBlank() && !isAmountValid -> {
Text(
text = strings.lnurlAmountError(state.minSats, state.maxSats),
color = MaterialTheme.colorScheme.error
)
}
conversionLabel != null -> {
Text(
text = conversionLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
}
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
else -> {}
}
},
enabled = !isFixed,
singleLine = true,
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.End,
fontFamily = FontFamily.Monospace
),
keyboardOptions = KeyboardOptions(
keyboardType = if (activeUnit == AmountUnit.SATS) KeyboardType.Number else KeyboardType.Decimal,
imeAction = ImeAction.Done
),
modifier = Modifier.weight(1f)
)
// ── Offset the picker upward by half the supporting-text height
// so its selected item aligns with the text field's input
// area (the visible text) rather than the geometric centre of
// the entire OutlinedTextField including the sub-label below.
UnitWheelPicker(
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
onIndexSelected = { newIndex ->
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
if (newUnit != activeUnit) {
activeUnit = newUnit
amountText = ""
}
},
modifier = Modifier
.width(64.dp)
.offset(y = -supportingTextHeight / 2)
)
}
// ── Gap after amount row — accounts for the picker extending below
// (picker is 96 dp, text field input area ~56 dp → ~40 dp
// overhang below, minus the offset above). Keep this compact so
// all inputs + title + button feel evenly spaced.
Spacer(Modifier.height(21.dp))
// ── Comment field ─────────────────────────────────────────────────
if (state.commentAllowed > 0) {
OutlinedTextField(
value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
label = { Text(strings.commentOptional(state.commentAllowed)) },
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
modifier = Modifier.fillMaxWidth()
)
@@ -154,17 +251,18 @@ internal fun LnurlPayForm(
Spacer(Modifier.height(16.dp))
// ── Pay button — unchanged ────────────────────────────────────────
// ── Pay button ────────────────────────────────────────────────────
Button(
onClick = {
if (sats != null && isAmountValid) {
vm.fetchLnurlInvoice(
rawRes = state.rawRes,
lnurl = state.lnurl,
domain = state.domain,
rawRes = state.rawRes,
lnurl = state.lnurl,
domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings
comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings,
contactId = existingContact?.id
)
}
},
@@ -172,8 +270,8 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth()
) {
Text(
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
else strings.lnurlPayButton(amount)
if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
else strings.lnurlPayButton(satsLabelForButton)
)
}
}
@@ -191,4 +289,3 @@ internal fun LnurlPayForm(
)
}
}
@@ -45,6 +45,7 @@ fun SendScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel,
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> },
onViewContact: ((contactId: String) -> Unit)? = null,
sendStrings: SendStrings
) {
val strings = LocalAppStrings.current
@@ -307,7 +308,10 @@ fun SendScreen(
secondaryLabel = strings.sendAnother,
onSecondary = { vm.resetSendState(); input = "" },
lnurl = state.lnurl, // null for plain Bolt11
onSaveContact = vm::saveContactFromSend
onSaveContact = vm::saveContactFromSend,
contactName = state.contactName,
contactId = state.contactId,
onViewContact = onViewContact
)
}
@@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.util.Bip21Parser
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -35,35 +38,32 @@ sealed class SendEvent {
object PaymentCompleted : SendEvent()
}
// ── String bundle injected at the call site ───────────────────────────────────
/**
* All user-visible strings produced by [SendViewModel].
* Populate from [LocalAppStrings.current] in the owning Composable and pass
* to [SendViewModel.scan], [SendViewModel.payBolt11], etc.
*/
data class SendStrings(
val lnurlAuthNotSupported : String,
val unrecognisedInput : String,
val paymentFailed : String,
val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String,
val onChainNotSupported : String,
val channelRequestNotSupported : String,
val couldNotResolveAddress : String,
val emptyResponse : String,
val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String,
val authFailed : (msg: String) -> String,
val lnurlAuthNotSupported : String,
val unrecognisedInput : String,
val paymentFailed : String,
val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String,
val onChainNotSupported : String,
val channelRequestNotSupported : String,
val couldNotResolveAddress : String,
val emptyResponse : String,
val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String,
val authFailed : (msg: String) -> String,
)
class SendViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository,
private val scanner : LnurlScanner,
private val payer : LnurlPayer,
private val poller : PaymentPoller,
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository,
private val scanner : LnurlScanner,
private val payer : LnurlPayer,
private val poller : PaymentPoller,
private val contactRepo : ContactRepository, // ← new (Step 5)
private val paymentCacheRepo : PaymentCacheRepository, // ← new (Step 8)
) : ViewModel() {
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
val events: SharedFlow<SendEvent> = _events
@@ -75,17 +75,18 @@ class SendViewModel(
}
// ── Entry point ──────────────────────────────────────────────────────────
fun scan(rawInput: String, strings: SendStrings) {
val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput)
val type = SendInputDetector.detect(rawInput)
Timber.d("SCAN [DETECT] input='$rawInput' → type=$type")
when (type) {
SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported"
strings.lnurlAuthNotSupported
else
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
strings.unrecognisedInput
_sendState.value = SendState.Error(msg)
}
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
@@ -95,10 +96,11 @@ class SendViewModel(
}
}
// ── Payment ──────────────────────────────────────────────────────────────
// ── Payment — Bolt11 ─────────────────────────────────────────────────────
fun payBolt11(bolt11: String, strings: SendStrings) {
viewModelScope.launch {
val decoded = _sendState.value as? SendState.Bolt11Decoded
val decoded = _sendState.value as? SendState.Bolt11Decoded
if (decoded == null) {
Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}")
return@launch
@@ -110,142 +112,38 @@ class SendViewModel(
handlePaymentResult(
paymentHash = response.paymentHash,
amountSats = decoded.amountSats,
bolt11 = decoded.bolt11,
memo = decoded.memo,
pubkey = decoded.payee,
alias = decoded.nodeAlias,
strings
bolt11 = decoded.bolt11,
memo = decoded.memo,
pubkey = decoded.payee,
alias = decoded.nodeAlias,
strings = strings,
contactId = null, // plain bolt11 path — no contact context
)
}
.onFailure { e ->
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.paymentFailed) // ← "Payment failed"
parseApiError(e, strings.paymentFailed)
)
}
}
}
private suspend fun handlePaymentResult(
paymentHash : String,
amountSats : Long,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
strings : SendStrings,
lnurl : String? = null
) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when {
// ── Settled immediately
detail?.paid == true -> {
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, lnurl)
}
// ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl)
is PaymentPoller.PollResult.Failed ->
_sendState.value = SendState.Error(strings.paymentFailed)
is PaymentPoller.PollResult.TimedOut ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// hardening against a null detail
detail == null -> {
// Detail fetch failed entirely — treat as pending and poll once
Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, lnurl)
else ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Failed or unknown
else -> {
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
}
private fun emitPaymentSent(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
lnurl : String? = null
) {
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias),
lnurl = lnurl
)
_events.tryEmit(SendEvent.PaymentCompleted)
}
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings)
}
}
private suspend fun executeLnurlPayment(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
if (result.isSuccess) {
handlePaymentResult(
paymentHash = result.getOrThrow(),
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
bolt11 = null,
memo = null,
pubkey = null,
alias = null,
strings = strings,
lnurl = lnurl
)
} else {
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
}
}
// ── Payment — LNURL (amount form → confirmation card) ────────────────────
/**
* Called from [LnurlPayForm] when the user taps Pay.
* Fetches the BOLT-11 from the LNURL callback and moves to [SendState.LnurlInvoiceReady].
* [contactId] is the Room PK of the contact associated with this address, or null if unknown.
*/
fun fetchLnurlInvoice(
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
rawRes : LnurlScanResponse,
lnurl : String,
domain : String,
amountMsat : Long,
comment : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 3)
) {
viewModelScope.launch {
_sendState.value = SendState.Scanning
@@ -255,7 +153,7 @@ class SendViewModel(
}.getOrElse { e ->
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient"
parseApiError(e, strings.couldNotFetchInvoice)
)
return@launch
}
@@ -265,7 +163,7 @@ class SendViewModel(
}.getOrElse { e ->
Timber.e("LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
parseApiError(e, strings.couldNotDecodeInvoice)
)
return@launch
}
@@ -275,8 +173,11 @@ class SendViewModel(
hints = decoded.routeHints
)
Timber.d("LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}")
Timber.d(
"LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size} " +
"contactId=$contactId"
)
_sendState.value = SendState.LnurlInvoiceReady(
bolt11 = bolt11,
@@ -294,7 +195,8 @@ class SendViewModel(
routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt
expiresAt = decoded.expiresAt,
contactId = contactId, // ← new (Step 3)
)
resolveAliasesInBackground(
@@ -313,6 +215,10 @@ class SendViewModel(
}
}
/**
* Called from the confirmation card. Tries direct bolt11 pay first; falls back to
* the LNURL-pay protocol. In both cases [contactId] is threaded through to [emitPaymentSent].
*/
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
viewModelScope.launch {
_sendState.value = SendState.Paying
@@ -327,18 +233,192 @@ class SendViewModel(
pubkey = state.payee,
alias = state.nodeAlias,
strings = strings,
lnurl = state.lnurl
contactId = state.contactId, // ← new (Step 5)
)
return@launch
}
Timber.w("LNURL [PAY INVOICE DIRECT FAIL] " +
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl")
executeLnurlPayment(state.rawRes, state.lnurl, state.amountMsat, state.comment, strings)
Timber.w(
"LNURL [PAY INVOICE DIRECT FAIL] " +
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl"
)
executeLnurlPayment(
rawRes = state.rawRes,
lnurl = state.lnurl,
amountMsat = state.amountMsat,
comment = state.comment,
strings = strings,
contactId = state.contactId, // ← new (Step 5)
)
}
}
/**
* Direct LNURL-pay path (no pre-fetched invoice). Used by [payLnurl] which is called
* from the amount form when the user bypasses the confirmation card.
*/
fun payLnurl(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
) {
viewModelScope.launch {
_sendState.value = SendState.Paying
// No contactId available on this path (legacy/manual scan)
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings, contactId = null)
}
}
fun resetSendState() { _sendState.value = SendState.Idle }
// ── Private: payment execution ───────────────────────────────────────────
private suspend fun executeLnurlPayment(
rawRes : LnurlScanResponse,
lnurl : String,
amountMsat: Long,
comment : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 5)
) {
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
if (result.isSuccess) {
handlePaymentResult(
paymentHash = result.getOrThrow(),
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
bolt11 = null,
memo = null,
pubkey = null,
alias = null,
strings = strings,
contactId = contactId, // ← new (Step 5)
)
} else {
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
}
}
private suspend fun handlePaymentResult(
paymentHash : String,
amountSats : Long,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
strings : SendStrings,
contactId : String?, // ← new (Step 5)
) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when {
// ── Settled immediately
detail?.paid == true -> {
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, contactId)
}
// ── Hold invoice — poll
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
is PaymentPoller.PollResult.Failed ->
_sendState.value = SendState.Error(strings.paymentFailed)
is PaymentPoller.PollResult.TimedOut ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Detail fetch failed entirely — treat as pending and poll once
detail == null -> {
Timber.w("SEND [DETAIL NULL] polling as fallback")
when (val result = poller.poll(paymentHash)) {
is PaymentPoller.PollResult.Settled ->
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
else ->
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
// ── Failed or unknown
else -> {
_sendState.value = SendState.Error(strings.paymentFailed)
}
}
}
/**
* Terminal success path. Order of operations is deliberate:
*
* 1. Resolve contact name (read-only, no ordering constraint)
* 2. Upsert the payment row — guarantees the FK exists in the payments table
* 3. Write the TxContactCrossRef — FK now satisfied, cannot dangle
* 4. Emit [SendState.PaymentSent] — UI unblocks only after all DB writes are committed
*/
private suspend fun emitPaymentSent(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
contactId : String?, // ← new (Step 5/8)
) {
val pendingRecord = PaymentRecord.fromPayment(
paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias
)
var contactName: String? = null
if (contactId != null) {
// ── 1. Resolve display name ──────────────────────────────────────
val contact: ContactEntity? = try {
contactRepo.getById(contactId)
} catch (e: Exception) {
Timber.w("SEND [CONTACT LOOKUP] could not resolve contact $contactId: ${e.message}")
null
}
contactName = contact?.localAlias
?: contact?.displayName
?: contact?.name
// ── 2. Guarantee the payment row exists before writing the FK ────
// upsertPayment uses INSERT OR REPLACE — idempotent if the
// background sync already wrote this row.
runCatching { paymentCacheRepo.upsertPayment(pendingRecord) }
.onFailure { Timber.e("SEND [UPSERT ERR] $paymentHash: ${it.message}") }
// ── 3. Link payment → contact (FK now guaranteed to exist) ───────
runCatching {
contactRepo.linkPaymentToContact(
paymentHash = paymentHash,
contactId = contactId,
role = "recipient",
)
}.onFailure { Timber.e("SEND [LINK ERR] $paymentHash$contactId: ${it.message}") }
}
// ── 4. Unblock UI — only after all DB writes complete ────────────────
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = pendingRecord,
contactId = contactId,
contactName = contactName,
)
_events.tryEmit(SendEvent.PaymentCompleted)
}
// ── Scan handlers ────────────────────────────────────────────────────────
private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
@@ -348,18 +428,14 @@ class SendViewModel(
when {
bolt11 != null -> scan(bolt11, strings)
lnurl != null -> scan(lnurl, strings)
else -> _sendState.value = SendState.Error(
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
)
else -> _sendState.value = SendState.Error(strings.onChainNotSupported)
}
}
private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") {
_sendState.value = SendState.Error(
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
)
_sendState.value = SendState.Error(strings.channelRequestNotSupported)
return
}
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
@@ -370,20 +446,20 @@ class SendViewModel(
.onSuccess { raw ->
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) {
"withdrawRequest" -> {emitWithdrawScanned(raw, httpsUrl)}
"withdrawRequest" -> emitWithdrawScanned(raw, httpsUrl)
"payRequest" -> {
lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
}
else -> _sendState.value = SendState.Error(
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag"
strings.unsupportedType(raw.tag ?: "unknown")
)
}
}
.onFailure { e ->
Timber.e("LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address"
parseApiError(e, strings.couldNotResolveAddress)
)
}
}
@@ -428,7 +504,7 @@ class SendViewModel(
.onFailure { e ->
Timber.e("SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
parseApiError(e, strings.couldNotDecodeInvoice)
)
}
}
@@ -452,10 +528,10 @@ class SendViewModel(
if (!fromCache) lnurlCache.put(normalized, raw)
when (raw.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
"withdrawRequest" -> emitWithdrawScanned(raw, normalized)
null -> _sendState.value = SendState.Error(strings.emptyResponse)
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
null -> _sendState.value = SendState.Error(strings.emptyResponse)
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
}
}
}
@@ -483,19 +559,19 @@ class SendViewModel(
payee : String?,
routeHints : List<RouteHintHop>,
copyAlias : (SendState, String) -> SendState?,
copyHint : (SendState, String, String) -> SendState?
copyHint : (SendState, String, String) -> SendState?,
) {
payee?.let { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyAlias(it, alias) ?: it }
}
}
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch
val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyHint(it, pubkey, alias) ?: it }
}
}
}
}
}
@@ -1,90 +1,81 @@
package com.bitcointxoko.gudariwallet.util
/**
* Converts a satoshi amount to its fiat equivalent.
*
* @param sats Amount in satoshis.
* @param satsPerFiat How many sats equal 1 fiat unit (e.g. if BTC = $100,000 then
* satsPerFiat = 100,000,000 / 100,000 = 1,000 sats per dollar).
* @return Fiat amount as a Double.
*/
import java.text.DecimalFormatSymbols
import java.util.Locale
// ── Public API ────────────────────────────────────────────────────────────────
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
sats / satsPerFiat
/**
* Formats a fiat amount for display with a currency symbol prefix.
*
* Tiers (bitcoin.design rules):
* - amount >= 1.00 → 2 dp, e.g. "$43.25"
* - 0.01 <= amount < 1.00 → 4 dp, e.g. "$0.0043"
* - amount < 0.01 (sub-cent)→ dynamic precision with "~" prefix,
* e.g. "~$0.0039" (2 significant digits after leading zeros)
* - amount == 0.0 → "$0.00"
*
* @param amount Fiat amount to format (always pass a positive value; sign is handled by caller).
* @param currency ISO 4217 currency code (e.g. "USD", "EUR").
* @return Formatted string with symbol, e.g. "$43.25" or "~$0.0039".
*/
fun formatFiat(amount: Double, currency: String): String {
val symbol = currencySymbol(currency)
return when {
amount == 0.0 -> "${symbol}0.00"
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
else -> formatSubCent(amount, symbol)
amount == 0.0 -> "${symbol}${localiseDecimal("0.00")}"
amount >= 1.0 -> {
val formatted = "%.2f".format(Locale.US, amount)
val isExact = formatted.toDouble() == amount
val prefix = if (isExact) "" else "~"
"$prefix$symbol${localiseDecimal(formatted)}"
}
amount >= 0.01 -> formatWithSigDigits(amount, symbol, minDecimals = 2)
else -> formatWithSigDigits(amount, symbol, minDecimals = 0)
}
}
/**
* Converts sats → fiat and formats in one call, applying bitcoin.design display rules.
*
* Use this everywhere you display a sat amount alongside a fiat equivalent.
*
* @param sats Absolute satoshi count (always positive; caller adds +/ sign).
* @param satsPerFiat Exchange rate: sats per 1 fiat unit.
* @param currency ISO 4217 currency code.
* @return Formatted fiat string, e.g. "$12.50", "~$0.0039", or "$0.00".
*/
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
formatFiat(satsToFiat(sats, satsPerFiat), currency)
fun fiatLabel(
sats: Long?,
fiatRate: Double?,
fiatCurrency: String?,
minSats: Long = 1L
): String? =
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
else null
// ── Internal helpers ──────────────────────────────────────────────────────────
/**
* Handles the sub-cent case (0 < amount < 0.01).
*
* Finds the first two significant (non-zero) digits after the decimal point
* and rounds to that precision, prefixing with "~" to signal approximation.
*
* Examples:
* 0.003887 → "~$0.0039"
* 0.000038 → "~$0.000038"
* 0.0000431 → "~$0.000043"
*/
private fun formatSubCent(amount: Double, symbol: String): String {
// Render with enough decimal places to capture at least 2 significant digits
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
val dotIdx = raw.indexOf('.')
if (dotIdx == -1) return "~$symbol$raw"
private fun formatWithSigDigits(amount: Double, symbol: String, minDecimals: Int): String {
// Use Locale.US so "." is always the decimal char — safe for arithmetic & comparison
val raw = "%.15f".format(Locale.US, amount)
val dotIdx = raw.indexOf('.')
if (dotIdx == -1) return "$symbol${localiseDecimal(raw)}"
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000"
val firstSigIdx = decimals.indexOfFirst { it != '0' }
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
val decimals = raw.substring(dotIdx + 1)
val firstSigIdx = decimals.indexOfFirst { it != '0' }
// Keep all leading zeros + 2 significant digits
val precision = firstSigIdx + 2
val rounded = "%.${precision}f".format(amount)
if (firstSigIdx == -1) return "${symbol}${localiseDecimal("0.00")}"
// Only add "~" if rounding actually changed the value
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
val sigPrecision = firstSigIdx + 2
val precision = maxOf(sigPrecision, minDecimals)
val rounded = "%.${precision}f".format(Locale.US, amount)
val isExact = rounded.toDouble() == amount // safe: dot separator guaranteed
val prefix = if (isExact) "" else "~"
return "$prefix$symbol${localiseDecimal(rounded)}"
}
/**
* Returns the display symbol for a given ISO 4217 currency code.
* Falls back to the currency code + space for unlisted currencies.
* Converts a US-formatted decimal string (dot separator, no grouping)
* into the user's current locale format.
*
* "155.57" → "155,57" (DE locale)
* "0.0039" → "0,0039" (DE locale)
* "155.57" → "155.57" (US locale)
*/
private fun localiseDecimal(usFormatted: String): String {
val symbols = DecimalFormatSymbols.getInstance() // uses device default locale
val localeDecimalSep = symbols.decimalSeparator
// Only substitute if the locale actually uses a different separator
return if (localeDecimalSep == '.') usFormatted
else usFormatted.replace('.', localeDecimalSep)
}
private fun currencySymbol(currency: String): String = when (currency) {
"USD" -> "$"
"EUR" -> ""
@@ -96,12 +87,3 @@ private fun currencySymbol(currency: String): String = when (currency) {
"CHF" -> "Fr"
else -> "$currency "
}
/**
* Returns a formatted fiat string for [sats] at the given rate, or null if
* rate or currency is unavailable. Suppresses display for zero/null amounts.
*/
fun fiatLabel(sats: Long?, fiatRate: Double?, fiatCurrency: String?, minSats: Long = 1L): String? =
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
else null