20 Commits

Author SHA1 Message Date
rasputin 5f0badcdf5 bug fixes 2026-06-15 00:33:29 +02:00
rasputin 3c5539d2cb wire up historyviewmodel 2026-06-14 14:55:55 +02:00
rasputin 19970659b8 repository combine layer 2026-06-14 14:16:41 +02:00
rasputin c6885f6307 create mapper functions 2026-06-14 14:03:37 +02:00
rasputin d5c461c2e6 define ui facing models 2026-06-14 14:03:21 +02:00
rasputin 985c80373d fiat ux 2026-06-14 13:30:11 +02:00
rasputin bcea6451e7 contact details ui 2026-06-14 12:47:28 +02:00
rasputin 53cb600408 contact details ui 2026-06-13 23:22:27 +02:00
rasputin effe8f4b04 fix: contact name wiped on refresh, migrate to paymentHash instead of checkingId 2026-06-13 22:52:12 +02:00
rasputin c817768e8a enhancement: search contacts 2026-06-13 20:57:17 +02:00
rasputin 0e7469053d enhancement: navigate to contact details from successful send 2026-06-13 20:16:40 +02:00
rasputin ebeac511b7 enhancement: show contact on successful send 2026-06-13 20:01:34 +02:00
rasputin 2e11cecbeb refactor: extract PaymentSuccessContent 2026-06-13 17:50:17 +02:00
rasputin e57414de17 feat: send from contact details 2026-06-13 16:09:58 +02:00
rasputin 0aa55710a9 enhancement: better contact detail screen ux 2026-06-13 15:57:46 +02:00
rasputin ef03a986a4 fix: multiple filters break contact search 2026-06-13 14:53:31 +02:00
rasputin 2bec9f3908 feat: search payments by contact 2026-06-13 14:30:17 +02:00
rasputin f9991622b3 feat: show contact name on payment rows 2026-06-13 14:09:33 +02:00
rasputin f931405c90 feat: add contacts 1 2026-06-13 14:00:59 +02:00
rasputin c3490808c0 localisation: app biometric lock 2026-06-12 21:44:43 +02:00
47 changed files with 4544 additions and 788 deletions
+16
View File
@@ -22,5 +22,21 @@
} }
], ],
"elementType": "File", "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 "minSdkVersionForDexing": 26
} }
@@ -0,0 +1,200 @@
package com.bitcointxoko.gudariwallet.data
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()
// --- Observe ---
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
dao.observeAllWithAddresses()
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(
localAlias: String,
lnAddress: String? = null,
lnUrl: String? = null,
pictureUrl: String? = null
): ContactEntity {
val contact = ContactEntity(
localAlias = localAlias,
displayName = null,
name = null,
pictureUrl = pictureUrl,
nostrPubkey = null,
nip05 = null,
about = null,
lnAddress = lnAddress
)
dao.insert(contact)
// Also add to payment_addresses table if lnAddress provided
lnAddress?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
address = it,
isDefault = true
)
)
}
lnUrl?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LNURL.name,
address = it,
isDefault = lnAddress == null // default only if no lnAddress
)
)
}
return contact
}
// --- Update ---
suspend fun updateAlias(id: String, newAlias: String) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
}
suspend fun updatePicture(id: String, pictureUrl: String?) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
}
// --- Payment addresses ---
suspend fun addAddress(
contactId: String,
type: PaymentAddressType,
address: String,
label: String? = null,
makeDefault: Boolean = false
) {
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.insertAddress(
PaymentAddressEntity(
contactId = contactId,
type = type.name,
address = address,
label = label,
isDefault = makeDefault
)
)
}
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)
}
// --- Delete ---
suspend fun deleteContact(id: String) {
dao.deleteById(id) // cascades to payment_addresses
}
// --- Lookup (for send flow) ---
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress)
suspend fun findByLnurl(lnurl: String): ContactEntity? =
dao.findByAddress(lnurl)
// --- Contact-Tx link ---
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(paymentHash)
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(paymentHash: String, contactId: String) {
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
dao.unlinkPaymentFromContact(paymentHash, contactId)
}
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
dao.observePaymentIdsForContact(contactId)
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames()
.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 findPaymentHashesByContactName(query: String): List<String> =
dao.findPaymentHashesByContactName(query)
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 package com.bitcointxoko.gudariwallet.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber import timber.log.Timber
private const val TAG = "FiatRateCache" private const val TAG = "FiatRateCache"
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
*/ */
class FiatRateCache(private val fiatDataStore: FiatDataStore) { class FiatRateCache(private val fiatDataStore: FiatDataStore) {
private val _fiatFlow = MutableStateFlow<Pair<String?, Double?>>(Pair(null, null))
/** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */
val fiatFlow: StateFlow<Pair<String?, Double?>> = _fiatFlow.asStateFlow()
// ── Rate ────────────────────────────────────────────────────────────────── // ── Rate ──────────────────────────────────────────────────────────────────
private var cachedRate : Double? = null private var cachedRate : Double? = null
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = rate cachedRate = rate
rateLastFetchedAt = now rateLastFetchedAt = now
cachedRateCurrency = currency cachedRateCurrency = currency
_fiatFlow.value = Pair(currency, rate)
fiatDataStore.persistRate(currency, rate) fiatDataStore.persistRate(currency, rate)
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted") Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
} }
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = null cachedRate = null
rateLastFetchedAt = 0L rateLastFetchedAt = 0L
cachedRateCurrency = "" cachedRateCurrency = ""
_fiatFlow.value = Pair(null, null)
} }
// ── Currency list ───────────────────────────────────────────────────────── // ── Currency list ─────────────────────────────────────────────────────────
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies") Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
} }
val currentRate: Double? val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
} }
@@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
import kotlinx.coroutines.flow.Flow
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentCacheRepository" private const val TAG = "PaymentCacheRepository"
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return if (payments.isEmpty()) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
runCatching { runCatching {
dao.insertAll(payments.map { it.toEntity(now) }) payments.forEach { payment ->
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})") 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 { }.onFailure {
Timber.w("PAYMENTS [MERGE ERR] ${it.message}") Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
} }
} }
/** Persist the first page of payments to the database. */ /** Persist the first page of payments to the database. */
suspend fun savePayments(payments: List<PaymentRecord>) { suspend fun savePayments(payments: List<PaymentRecord>) {
val now = System.currentTimeMillis() 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 ──────────────────────────────────────────────────────── // ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */ /** Returns a cached detail response, or null if not yet fetched this session. */
@@ -130,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
/** Upsert PaymentRecord to database. */ /** Upsert PaymentRecord to database. */
suspend fun upsertPayment(payment: PaymentRecord) { suspend fun upsertPayment(payment: PaymentRecord) {
val entity = payment.toEntity(savedAt = System.currentTimeMillis()) val e = payment.toEntity(savedAt = System.currentTimeMillis())
dao.insert(entity) 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}") Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
} }
@@ -159,4 +201,34 @@ class PaymentCacheRepository(context: Context) {
dao.deleteAll() dao.deleteAll()
Timber.d("PAYMENTS [CLEARED ] cache wiped") Timber.d("PAYMENTS [CLEARED ] cache wiped")
} }
/**
* Fetch payments by checkingId with the full PaymentFilter applied.
* Used by HistoryViewModel for contact name search supplement and
* contact ID filter. Amount/date/status are applied in SQL;
* direction and type are handled in-memory afterwards (same as queryAll).
*/
suspend fun queryByPaymentHashes(
paymentHashes : List<String>,
filter : PaymentFilter
): List<PaymentRecord> {
if (paymentHashes.isEmpty()) return emptyList()
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
filter.statuses.flatMap { s ->
when (s) {
StatusFilter.COMPLETED -> listOf("success", "complete", "paid")
StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight")
StatusFilter.FAILED -> listOf("failed", "error", "expired")
}
}.joinToString(",")
}
return dao.getByPaymentHashesFiltered(
paymentHashes = paymentHashes,
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt,
statusPattern = statusPattern
).map { it.toDomain() }
}
} }
@@ -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()
}
@@ -14,9 +14,12 @@ import androidx.sqlite.db.SupportSQLiteDatabase
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
NwcKeyEntity::class, NwcKeyEntity::class,
NwcBudgetEntity::class NwcBudgetEntity::class,
ContactEntity::class,
PaymentAddressEntity::class,
TxContactCrossRef::class,
], ],
version = 7, version = 10,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -26,6 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao abstract fun nwcKeyDao(): NwcKeyDao
abstract fun contactDao(): ContactDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
@@ -124,6 +128,97 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS contacts (
id TEXT NOT NULL PRIMARY KEY,
localAlias TEXT,
displayName TEXT,
name TEXT,
pictureUrl TEXT,
nostrPubkey TEXT,
nip05 TEXT,
about TEXT,
lnAddress TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
ON contacts(nostrPubkey)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
ON contacts(lnAddress)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS payment_addresses (
id TEXT NOT NULL PRIMARY KEY,
contactId TEXT NOT NULL,
type TEXT NOT NULL,
address TEXT NOT NULL,
label TEXT,
isDefault INTEGER NOT NULL DEFAULT 0,
addedAt INTEGER NOT NULL,
FOREIGN KEY(contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
ON payment_addresses(contactId)
""".trimIndent())
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
createdAt INTEGER NOT NULL,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
}
}
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 = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -137,7 +232,10 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_3_4, MIGRATION_3_4,
MIGRATION_4_5, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7 MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10
) )
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
@@ -0,0 +1,168 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.*
import kotlinx.coroutines.flow.Flow
data class ContactWithAddresses(
@Embedded val contact: ContactEntity,
@Relation(
parentColumn = "id",
entityColumn = "contactId"
)
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 {
// --- Contacts ---
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAll(): Flow<List<ContactEntity>>
@Transaction
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
@Transaction
@Query("SELECT * FROM contacts WHERE id = :id")
fun observeById(id: String): Flow<ContactWithAddresses?>
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): ContactEntity?
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
suspend fun findByAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
suspend fun findByLnAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(contact: ContactEntity)
@Update
suspend fun update(contact: ContactEntity)
@Query("DELETE FROM contacts WHERE id = :id")
suspend fun deleteById(id: String)
// --- Payment addresses ---
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAddress(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)
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
// Clear the isDefault flag for a type before setting a new default
@Query("""
UPDATE payment_addresses
SET isDefault = 0
WHERE contactId = :contactId AND type = :type
""")
suspend fun clearDefault(contactId: String, type: String)
// Insert a link
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link
@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 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.paymentHash = :paymentHash
""")
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact
@Query("""
SELECT paymentHash FROM tx_contact_links
WHERE contactId = :contactId
ORDER BY createdAt DESC
""")
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// 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.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>>
// Returns all (paymentHash, contactId, displayName) triples — used by
// PaymentFeedRepository to build full ContactSummary lists per payment.
@Query("""
SELECT tcl.paymentHash AS paymentHash,
c.id AS contactId,
COALESCE(c.localAlias, c.displayName, c.name) AS displayName
FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
""")
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>>
// For text search matching contact names — returns paymentHashes of matched payments
@Query("""
SELECT tcl.paymentHash FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""")
suspend fun findPaymentHashesByContactName(query: String): List<String>
// For filter-by-contact — returns paymentHashs for specific contact IDs
@Query("""
SELECT DISTINCT paymentHash FROM tx_contact_links
WHERE contactId IN (:contactIds)
""")
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
@Query("""
SELECT c.* FROM contacts c
INNER JOIN tx_contact_links x ON c.id = x.contactId
WHERE x.paymentHash = :paymentHash
""")
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity>
}
@@ -0,0 +1,36 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "contacts",
indices = [
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
Index(value = ["lnAddress"])
]
)
data class ContactEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
// --- Display identity ---
val localAlias: String?, // user-set name — highest display priority
val displayName: String?, // future: from nostr kind 0 display_name
val name: String?, // future: from nostr kind 0 name
val pictureUrl: String?, // future: from nostr kind 0 picture
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
val nostrPubkey: String?, // 32-byte hex; unique index
val nip05: String?, // e.g. user@domain.com
val about: String?, // bio / notes
// --- Payment fast-access (denormalized for single-row lookup) ---
val lnAddress: String?, // primary Lightning Address (lud16)
// --- Metadata ---
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,42 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "payment_addresses",
foreignKeys = [
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("contactId")]
)
data class PaymentAddressEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val contactId: String, // FK → contacts.id
val type: String, // PaymentAddressType.name (stored as String)
val address: String, // the raw address value
val label: String? = null, // e.g. "personal", "savings"
val isDefault: Boolean = false,
val addedAt: Long = System.currentTimeMillis()
)
// Keep as a simple enum in the same file or a separate file
enum class PaymentAddressType {
LIGHTNING_ADDRESS, // user@domain.com (lud16)
LNURL, // bech32 lnurl (lud06)
ON_CHAIN, // Bitcoin address
SILENT_PAYMENT, // BIP-352 sp1...
BOLT12_OFFER, // lno...
BIP353 // ₿user@domain.com
}
@@ -0,0 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName(
val paymentHash : String,
val contactName : String
)
@@ -4,6 +4,8 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface PaymentDao { interface PaymentDao {
@@ -13,10 +15,53 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(payment: PaymentRecordEntity) 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. */ /** Returns all rows ordered by time descending. */
@Query("SELECT * FROM payment_records ORDER BY time DESC") @Query("SELECT * FROM payment_records ORDER BY time DESC")
suspend fun getAll(): List<PaymentRecordEntity> suspend fun getAll(): List<PaymentRecordEntity>
/** Reactive version of [getAll] — emits a new list whenever any row changes. */
@Query("SELECT * FROM payment_records ORDER BY COALESCE(createdAt, CAST(time AS INTEGER)) DESC")
fun observeAll(): Flow<List<PaymentRecordEntity>>
/** A single page, ordered newest-first. */ /** A single page, ordered newest-first. */
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity> suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
@@ -32,6 +77,30 @@ interface PaymentDao {
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1") @Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity? suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
@Query("""
SELECT * FROM payment_records
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)
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
AND (:statusPattern IS NULL OR
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
ORDER BY createdAt DESC, time DESC
""")
suspend fun getByPaymentHashesFiltered(
paymentHashes : List<String>,
minAmountMsat : Long? = null,
maxAmountMsat : Long? = null,
minCreatedAt : Long? = null,
maxCreatedAt : Long? = null,
statusPattern : String? = null
): List<PaymentRecordEntity>
@Query("DELETE FROM payment_records") @Query("DELETE FROM payment_records")
suspend fun deleteAll() suspend fun deleteAll()
@@ -0,0 +1,16 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "tx_contact_links",
primaryKeys = ["paymentHash", "contactId"]
)
data class TxContactCrossRef(
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,
)
}
@@ -469,6 +469,19 @@ val EnHomeStrings = AppStrings(
failedToCreateConnection = "Failed to create connection", failedToCreateConnection = "Failed to create connection",
failedToDeleteConnection = "Failed to delete connection", failedToDeleteConnection = "Failed to delete connection",
failedToLoadConnection = "Failed to load connection", failedToLoadConnection = "Failed to load connection",
) ),
lock = LockStrings(
appLockTitle = "App Lock",
appLockDescription = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
appLockToggleLabel = "Lock app on open",
appLockToggleEnabled = "Enabled (recommended)",
appLockToggleDisabled = "Disabled",
appName = "Gudari Wallet",
authenticateToContinue = "Authenticate to continue",
unlockButton = "Unlock",
biometricPromptTitle = "Unlock Gudari Wallet",
biometricPromptSubtitle = "Authenticate to access your wallet",
),
) )
@@ -470,5 +470,17 @@ val EsHomeStrings = AppStrings(
failedToCreateConnection = "Error al crear la conexión", failedToCreateConnection = "Error al crear la conexión",
failedToDeleteConnection = "Error al eliminar la conexión", failedToDeleteConnection = "Error al eliminar la conexión",
failedToLoadConnection = "Error al cargar la conexión", failedToLoadConnection = "Error al cargar la conexión",
) ),
lock = LockStrings(
appLockTitle = "Bloqueo de App",
appLockDescription = "Solicitar autenticación biométrica (huella, cara o PIN) cada vez que se abra Gudari Wallet.",
appLockToggleLabel = "Bloquear al abrir",
appLockToggleEnabled = "Activado (recomendado)",
appLockToggleDisabled = "Desactivado",
appName = "Gudari Wallet",
authenticateToContinue = "Autentícate para continuar",
unlockButton = "Desbloquear",
biometricPromptTitle = "Desbloquear Gudari Wallet",
biometricPromptSubtitle = "Autentícate para acceder a tu cartera",
),
) )
@@ -470,5 +470,17 @@ val EuStrings = AppStrings(
failedToCreateConnection = "Huts egin du konexioa sortzean", failedToCreateConnection = "Huts egin du konexioa sortzean",
failedToDeleteConnection = "Huts egin du konexioa ezabatzean", failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
failedToLoadConnection = "Huts egin du konexioa kargatzean", failedToLoadConnection = "Huts egin du konexioa kargatzean",
) ),
lock = LockStrings(
appLockTitle = "App Blokeoa",
appLockDescription = "Autentifikazio biometrikoa (hatz-marka, aurpegia edo PINa) eskatu Gudari Wallet irekitzen den bakoitzean.",
appLockToggleLabel = "Aplikazioa irekitzean blokeatu",
appLockToggleEnabled = "Gaituta (gomendatua)",
appLockToggleDisabled = "Desgaituta",
appName = "Gudari Wallet",
authenticateToContinue = "Autentifikatu jarraitzeko",
unlockButton = "Desblokeatu",
biometricPromptTitle = "Desblokeatu Gudari Wallet",
biometricPromptSubtitle = "Autentifikatu zure zorrora sartzeko",
)
) )
@@ -154,7 +154,8 @@ data class AppStrings(
val onboarding: OnboardingStrings, val onboarding: OnboardingStrings,
val receive: ReceiveStrings, val receive: ReceiveStrings,
val history: HistoryStrings, val history: HistoryStrings,
val nwc: NwcStrings val nwc: NwcStrings,
val lock: LockStrings
) )
data class OnboardingStrings( data class OnboardingStrings(
@@ -479,4 +480,17 @@ data class NwcStrings(
val failedToCreateConnection : String, val failedToCreateConnection : String,
val failedToDeleteConnection : String, val failedToDeleteConnection : String,
val failedToLoadConnection : String, val failedToLoadConnection : String,
) )
data class LockStrings(
val appLockTitle: String,
val appLockDescription: String,
val appLockToggleLabel: String,
val appLockToggleEnabled: String,
val appLockToggleDisabled: String,
val appName: String,
val authenticateToContinue: String,
val unlockButton: String,
val biometricPromptTitle: String,
val biometricPromptSubtitle: String,
)
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.People
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -40,6 +41,7 @@ fun HomeTab(
vm: WalletViewModel, vm: WalletViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onNwcClick : () -> Unit, onNwcClick : () -> Unit,
onContactsClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -284,19 +286,25 @@ fun HomeTab(
} }
} }
} }
// ── NWC button — top-right corner ───────────────────────────────── Row(
IconButton(
onClick = onNwcClick,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(8.dp)
) { ) {
Icon( IconButton(onClick = onContactsClick) {
imageVector = Icons.Filled.Cable, Icon(
contentDescription = "Wallet Connect", imageVector = Icons.Filled.People,
tint = MaterialTheme.colorScheme.onSurfaceVariant contentDescription = "Contacts",
.copy(alpha = 0.7f) tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
) )
}
IconButton(onClick = onNwcClick) {
Icon(
imageVector = Icons.Filled.Cable,
contentDescription = "Wallet Connect",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
} }
} }
} }
@@ -23,13 +23,13 @@ sealed class ReceiveState {
val amountSats : Long, val amountSats : Long,
val memo : String, val memo : String,
val expiresAt : Long val expiresAt : Long
) : ReceiveState() ) : ReceiveState()
data class PaymentReceived( data class PaymentReceived(
val amountSats: Long, val amountSats: Long,
val memo : String?, val memo : String?,
val checkingId : String val paymentHash : String
) : ReceiveState() ) : ReceiveState()
data class Error(val message: String) : ReceiveState() data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady( data class LnurlWithdrawReady(
val k1 : String, val k1 : String,
val callback : String, val callback : String,
@@ -85,14 +85,18 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(), override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String, override val paymentHash : String,
override val createdAt : Instant, override val createdAt : Instant,
override val expiresAt : Instant override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice ) : SendState(), DecodedInvoice
data object Paying : SendState() data object Paying : SendState()
data class PaymentSent( data class PaymentSent(
val amountSats : Long, val amountSats : Long,
val feeSats : Long? = null, val feeSats : Long? = null,
val paymentHash: String, val paymentHash: String,
val pendingRecord: PaymentRecord? = null val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState() ) : SendState()
data class Error(val message: String) : SendState() data class Error(val message: String) : SendState()
} }
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.ui package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager import android.content.ClipboardManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -57,6 +59,8 @@ import cafe.adriel.lyricist.Lyricist
import com.bitcointxoko.gudariwallet.MainActivity import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
@@ -70,6 +74,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -85,10 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
private const val ROUTE_HOME = "home" private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner" private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history" private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
private const val ROUTE_NWC = "nwc" private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}" private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
private const val ROUTE_CONTACTS = "contacts"
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable @Composable
fun WalletScreen( fun WalletScreen(
vm : WalletViewModel, vm : WalletViewModel,
@@ -122,14 +133,25 @@ fun WalletScreen(
repo = vm.repo, repo = vm.repo,
aliasRepo = vm.aliasRepo, aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache, paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
feedRepository = PaymentFeedRepository(
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
nodeAliasRepository = vm.aliasRepo,
fiatVm = vm.fiatVm
),
fiatCurrency = vm.selectedCurrency, fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit fiatSatsPerUnit = vm.fiatSatsPerUnit
) )
} }
val historyVm: HistoryViewModel = viewModel(factory = historyFactory) val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel( val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
) )
val contactsVm: ContactsViewModel = viewModel(
factory = ContactsViewModel.Factory(vm.contactRepo)
)
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -139,12 +161,16 @@ fun WalletScreen(
&& currentRoute != ROUTE_HISTORY && currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC && currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL && currentRoute != ROUTE_CONNECTION_DETAIL
&& currentRoute != ROUTE_CONTACTS
&& currentRoute != ROUTE_CONTACT_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER || currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC || currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL || currentRoute == ROUTE_CONNECTION_DETAIL
|| currentRoute == ROUTE_CONTACTS
|| currentRoute == ROUTE_CONTACT_DETAIL
// ── Notification tap ────────────────────────────────────────────────────── // ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value val hash = pendingPaymentHash.value
@@ -341,6 +367,9 @@ fun WalletScreen(
launchSingleTop = true launchSingleTop = true
} }
}, },
onContactsClick = {
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
},
lyricist = lyricist lyricist = lyricist
) )
} }
@@ -348,18 +377,23 @@ fun WalletScreen(
ReceiveScreen( ReceiveScreen(
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
onNavigateToPaymentDetail = { checkingId -> onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$checkingId") navController.navigate("payment_detail/$paymentHash")
} }
) )
} }
composable(TabItem.Send.route) { composable(TabItem.Send.route) {
SendScreen( SendScreen(
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
onViewDetails = { checkingId, record -> onViewDetails = { paymentHash, record ->
historyVm.primePayment(record) historyVm.primePayment(record)
navController.navigate("payment_detail/$checkingId") { navController.navigate("payment_detail/$paymentHash") {
launchSingleTop = true
}
},
onViewContact = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -398,34 +432,30 @@ fun WalletScreen(
onClose = { onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false) navController.popBackStack(ROUTE_HOME, inclusive = false)
}, },
onPaymentClick = { payment -> onPaymentClick = { item ->
navController.navigate("payment_detail/${payment.checkingId}") navController.navigate("payment_detail/${item.paymentHash}")
} }
) )
} }
// Payment detail — back → history (natural back stack) // Payment detail — back → history (natural back stack)
composable( composable(
route = ROUTE_PAYMENT_DETAIL, route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType }) arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: "" val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val historyState by historyVm.filteredState.collectAsStateWithLifecycle() val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
val fallbackState by historyVm.state.collectAsStateWithLifecycle() val item = remember(feedItems, paymentHash) {
val payment = remember(historyState, fallbackState, checkingId) { historyVm.findPayment(paymentHash)
(historyState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
?: (fallbackState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
} }
if (payment != null) { if (item != null) {
PaymentDetailScreen( PaymentDetailScreen(
payment = payment, item = item,
vm = historyVm, vm = historyVm,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} else { } else {
LaunchedEffect(checkingId) { historyVm.refresh() } LaunchedEffect(paymentHash) { historyVm.refresh() }
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -498,6 +528,50 @@ fun WalletScreen(
} }
} }
} }
composable(
ROUTE_CONTACTS,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
ContactsScreen(
viewModel = contactsVm,
onContactClick = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
}
)
}
composable(
route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel(
key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
)
ContactDetailScreen(
viewModel = detailVm,
onBack = { navController.popBackStack() },
onPayAddress = { address ->
vm.prefillSend(address)
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // don't restore stale SendScreen state
}
}
)
}
} }
// ── Clipboard banner — floats over content, slides in from top ──── // ── Clipboard banner — floats over content, slides in from top ────
@@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -20,7 +23,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendEvent import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -30,6 +35,7 @@ class WalletViewModel(
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository, val nwcCache : NwcCacheRepository,
val contactRepo : ContactRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore, private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
@@ -99,7 +105,8 @@ class WalletViewModel(
amountMsat: Long, amountMsat: Long,
comment : String?, comment : String?,
strings : SendStrings, 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) = fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings) sendVm.payLnurlInvoice(state, strings)
@@ -111,6 +118,16 @@ class WalletViewModel(
val sendEvents: SharedFlow<SendEvent> = sendVm.events val sendEvents: SharedFlow<SendEvent> = sendVm.events
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
private val _pendingSend = MutableStateFlow<String?>(null)
val pendingSend: StateFlow<String?> get() = _pendingSend
/** Called by ContactDetailScreen before navigating to the Send tab. */
fun prefillSend(address: String) { _pendingSend.value = address }
/** Called by SendScreen once it has consumed the prefilled address. */
fun consumePendingSend() { _pendingSend.value = null }
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) { fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
receiveVm.handleWithdrawResponse(raw, lnurl) receiveVm.handleWithdrawResponse(raw, lnurl)
} }
@@ -126,6 +143,32 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text) fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer() fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
sendVm.sendState
.map { state ->
val lnurl = (state as? SendState.LnurlReady)?.lnurl
?: return@map null
contactRepo.findByLnAddress(lnurl)
?: contactRepo.findByLnurl(lnurl)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
}
}
init { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
@@ -12,6 +12,7 @@ import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
@@ -37,6 +38,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val poller = PaymentPoller(repo) val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app) val nwcCache = NwcCacheRepository(app)
val contactRepo = ContactRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app) val lnAddressStore = LightningAddressStore(app)
@@ -50,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner, scanner = lnurlScanner,
payer = lnurlPayer, payer = lnurlPayer,
poller = poller, poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
) )
val clipboardVm = ClipboardViewModel( val clipboardVm = ClipboardViewModel(
@@ -65,6 +69,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
nwcCache = nwcCache, nwcCache = nwcCache,
contactRepo = contactRepo,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
fiatVm = fiatVm, fiatVm = fiatVm,
@@ -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)
)
}
}
@@ -0,0 +1,145 @@
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.*
import androidx.compose.ui.Alignment
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
/**
* Shared success screen used by both Send (PaymentSent) and Receive (PaymentReceived).
*
* @param title Headline string, e.g. strings.paymentSent / strings.receive.paymentReceived
* @param amountSats Amount in sats.
* @param fiatLabel Optional fiat conversion string (from [fiatLabel] util).
* @param subLine Optional secondary line below amount: fee text (send) or memo (receive).
* @param detailsEnabled Whether the Details button is clickable.
* @param onDetails Navigate to payment detail.
* @param secondaryLabel Label of the reset TextButton ("Send Another" / "Done").
* @param onSecondary Reset screen state.
* @param lnurl Pre-fill address for the save-contact sheet. Pass null to hide the button.
* @param onSaveContact Forward to vm.saveContactFromSend — omit (null) to hide save-contact UI.
*/
@Composable
fun PaymentSuccessContent(
title : String,
amountSats : Long,
fiatLabel : String?,
subLine : String? = null,
detailsEnabled : Boolean = true,
onDetails : () -> Unit,
secondaryLabel : String,
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),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(16.dp))
Text(text = title, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!subLine.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
text = subLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(32.dp))
// ── Save contact affordance (send-side only) ─────────────────────────
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
)
}
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")
}
}
}
Spacer(Modifier.height(32.dp))
Button(
onClick = onDetails,
enabled = detailsEnabled,
modifier = Modifier.fillMaxWidth()
) { Text(LocalAppStrings.current.details) }
Spacer(Modifier.height(32.dp))
TextButton(
onClick = onSecondary,
modifier = Modifier.fillMaxWidth()
) { Text(secondaryLabel) }
}
// ── Bottom sheet ─────────────────────────────────────────────────────────
if (showSaveSheet && lnurl != null && onSaveContact != null) {
// Detect whether the string is a Lightning Address (user@domain) or LNURL
val isLnAddress = lnurl.contains('@') && !lnurl.startsWith("lnurl", ignoreCase = true)
val initialType = if (isLnAddress)
PaymentAddressType.LIGHTNING_ADDRESS
else
PaymentAddressType.LNURL
CreateContactSheet(
initialAddress = lnurl,
initialAddressType = initialType,
onSave = { name, addressType, address ->
onSaveContact(name, addressType, address)
showSaveSheet = false
},
onDismiss = { showSaveSheet = false }
)
}
}
@@ -0,0 +1,794 @@
package com.bitcointxoko.gudariwallet.ui.contacts
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
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.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailScreen(
viewModel: ContactDetailViewModel,
onBack: () -> Unit,
onPayAddress: (address: String) -> Unit = {}
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = {
val title = when (val s = uiState) {
is ContactDetailUiState.Success ->
s.data.contact.localAlias
?: s.data.contact.displayName
?: s.data.contact.name
?: "Contact"
else -> "Contact"
}
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
},
actions = {
if (uiState is ContactDetailUiState.Success) {
if (isEditing) {
// Done button exits edit mode
TextButton(onClick = { isEditing = false }) {
Text("Done")
}
} else {
// Edit button opens edit mode
IconButton(onClick = { isEditing = true }) {
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
}
}
}
}
)
},
floatingActionButton = {
// FAB only visible in edit mode so users don't accidentally add
// addresses while browsing
if (uiState is ContactDetailUiState.Success && isEditing) {
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add payment address")
}
}
}
) { innerPadding ->
when (val state = uiState) {
is ContactDetailUiState.Loading -> {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ContactDetailUiState.NotFound -> {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
"Contact not found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
is ContactDetailUiState.Success -> {
ContactDetailContent(
state = state,
isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() },
onEditAddress = { addr -> editingAddress = addr },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
)
}
}
}
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
if (pendingDelete) {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Delete contact") },
text = { Text("This contact and all their addresses will be permanently removed.") },
confirmButton = {
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) {
Text(strings.cancel)
}
}
)
}
pendingAddressDelete?.let { addressId ->
AlertDialog(
onDismissRequest = { pendingAddressDelete = null },
title = { Text("Remove address") },
text = { Text("This payment address will be permanently removed from the contact.") },
confirmButton = {
TextButton(onClick = {
viewModel.removeAddress(addressId)
pendingAddressDelete = null
}) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingAddressDelete = null }) {
Text(strings.cancel)
}
}
)
}
// ── Add address sheet ─────────────────────────────────────────────────
if (showAddAddressSheet) {
AddAddressSheet(
onSave = { type, address, label, makeDefault ->
viewModel.addAddress(type, address, label, makeDefault)
viewModel.closeAddAddressSheet()
},
onDismiss = { viewModel.closeAddAddressSheet() }
)
}
// ── 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)
?.data?.contact?.localAlias ?: ""
EditAliasSheet(
currentAlias = currentAlias,
onSave = { newAlias ->
viewModel.updateAlias(newAlias)
viewModel.closeEditAliasSheet()
},
onDismiss = { viewModel.closeEditAliasSheet() }
)
}
}
// ── Main content ───────────────────────────────────────────────────────────────
@Composable
private fun ContactDetailContent(
state: ContactDetailUiState.Success,
isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit,
onEditAddress: (PaymentAddressEntity) -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier
) {
val contact = state.data.contact
val addresses = state.data.addresses
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
) {
// ── Avatar + name hero ─────────────────────────────────────────────
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "?"
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(72.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.height(12.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge
)
contact.nip05?.let { nip05 ->
Text(
text = nip05,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ── Payment addresses section ─────────────────────────────────────
item {
DetailSection(title = "Payment addresses") {
if (addresses.isEmpty()) {
Text(
text = "No addresses added yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
addresses.forEachIndexed { index, addr ->
AddressRow(
address = addr,
isEditing = isEditing,
onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) },
onEdit = { onEditAddress(addr) }
)
if (index < addresses.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
contact.about?.let { about ->
item {
DetailSection(title = "About") {
DetailRow(label = "Bio", value = about, maxLines = 4)
}
}
}
// ── Destructive actions (edit mode only) ─────────────────────────
if (isEditing) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onEditAlias,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Edit name")
}
OutlinedButton(
onClick = onDeleteContact,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Delete contact")
}
}
}
}
}
}
// ── Address row ────────────────────────────────────────────────────────────────
@Composable
private fun AddressRow(
address: PaymentAddressEntity,
isEditing: Boolean,
onPay: () -> Unit,
onRemove: () -> Unit,
onSetDefault: () -> Unit,
onEdit: () -> Unit
) {
val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
PaymentAddressType.LNURL.name -> "LNURL"
PaymentAddressType.ON_CHAIN.name -> "On-chain"
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
PaymentAddressType.BIP353.name -> "BIP-353"
else -> address.type
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = !isEditing,
onClickLabel = "Pay with this address"
) { onPay() }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// ── 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)
) {
Text(
text = typeLabel,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
address.label?.let { lbl ->
Text(
text = "· $lbl",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (address.isDefault) {
SuggestionChip(
onClick = {},
label = {
Text(
"default",
style = MaterialTheme.typography.labelSmall
)
},
modifier = Modifier.height(20.dp)
)
}
}
}
// ── Trailing actions ──────────────────────────────────────────
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
// Copy always available
CopyIconButton(
label = "Copy address",
value = address.address,
size = 36.dp
)
// Star + delete only in edit mode
if (isEditing) {
IconButton(
onClick = onEdit,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit address",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove address",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// ── Add address sheet ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddAddressSheet(
onSave: (type: PaymentAddressType, address: 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.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf("") }
var label by remember { mutableStateOf("") }
var makeDefault by remember { mutableStateOf(false) }
val canSave = address.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("Add 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; address = "" },
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
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 = address,
onValueChange = { address = 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(selectedType, address, 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(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
},
enabled = canSave
) { Text("Save") }
}
}
}
}
// ── 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)
@Composable
private fun EditAliasSheet(
currentAlias: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var alias by remember { mutableStateOf(currentAlias) }
val canSave = alias.isNotBlank()
LaunchedEffect(Unit) { focusRequester.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 name", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = alias,
onValueChange = { alias = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(alias)
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
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(alias) },
enabled = canSave
) { Text("Save") }
}
}
}
}
@@ -0,0 +1,124 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
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.*
import kotlinx.coroutines.launch
sealed class ContactDetailUiState {
data object Loading : ContactDetailUiState()
data object NotFound : ContactDetailUiState()
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
}
class ContactDetailViewModel(
private val contactId: String,
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
.map { contact ->
if (contact == null) ContactDetailUiState.NotFound
else ContactDetailUiState.Success(contact)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactDetailUiState.Loading
)
// ── Delete contact ─────────────────────────────────────────────────────
private val _pendingDelete = MutableStateFlow(false)
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
fun requestDelete() { _pendingDelete.value = true }
fun cancelDelete() { _pendingDelete.value = false }
fun confirmDelete(onDeleted: () -> Unit) {
viewModelScope.launch {
repo.deleteContact(contactId)
_pendingDelete.value = false
onDeleted()
}
}
// ── Edit alias ─────────────────────────────────────────────────────────
fun updateAlias(newAlias: String) {
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
}
// ── Payment addresses ──────────────────────────────────────────────────
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = type,
address = address.trim(),
label = label?.trim()?.takeIf { it.isNotEmpty() },
makeDefault = makeDefault
)
}
}
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) }
}
fun setDefaultAddress(address: PaymentAddressEntity) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = PaymentAddressType.valueOf(address.type),
address = address.address,
label = address.label,
makeDefault = true
)
}
}
// ── Add address sheet state ────────────────────────────────────────────
private val _showAddAddressSheet = MutableStateFlow(false)
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
// ── Edit alias sheet state ─────────────────────────────────────────────
private val _showEditAliasSheet = MutableStateFlow(false)
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
class Factory(
private val contactId: String,
private val repo: ContactRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactDetailViewModel(contactId, repo) as T
}
}
@@ -0,0 +1,151 @@
package com.bitcointxoko.gudariwallet.ui.contacts
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.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactPickerSheet(
contacts: List<ContactWithAddresses>,
onContactSelected: (contactId: String) -> Unit,
onCreateNew: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var search by remember { mutableStateOf("") }
val filtered = remember(contacts, search) {
if (search.isBlank()) contacts
else contacts.filter { c ->
val name = c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: ""
name.contains(search, ignoreCase = true)
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
// Search field
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Search") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
keyboardType = KeyboardType.Text,
autoCorrectEnabled = false
),
modifier = Modifier.fillMaxWidth()
)
// Contact list
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(filtered, key = { it.contact.id }) { item ->
val displayName = item.contact.localAlias
?: item.contact.displayName
?: item.contact.name
?: "Unknown"
ListItem(
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = item.addresses.firstOrNull()?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
},
modifier = Modifier.clickable {
onContactSelected(item.contact.id)
}
)
}
if (filtered.isEmpty() && search.isNotBlank()) {
item {
Text(
text = "No contacts match \"$search\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
// Create new contact button
HorizontalDivider()
TextButton(
onClick = onCreateNew,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Create new contact")
}
}
}
}
@@ -0,0 +1,314 @@
package com.bitcointxoko.gudariwallet.ui.contacts
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
viewModel: ContactsViewModel,
onContactClick: (contactId: String) -> Unit,
onBack: () -> Unit
) {
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 = {
TopAppBar(
title = { Text("Contacts") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
},
actions = {
IconButton(onClick = {
searchVisible = !searchVisible
if (!searchVisible) searchQuery = ""
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search contacts"
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add contact")
}
}
) { innerPadding ->
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()
}
when (val state = uiState) {
is ContactsUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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 }
)
}
}
}
// ── Empty state ──────────────────────────────────────────────────────────────
@Composable
private fun ContactsEmptyContent(
onAdd: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
Text(
text = "No contacts yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
text = "Tap + to add your first contact",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add contact")
}
}
}
// ── 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(
item: ContactWithAddresses,
onClick: () -> Unit
) {
val contact = item.contact
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
val primaryAddress = item.addresses
.firstOrNull { it.isDefault }
?: item.addresses.firstOrNull()
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = primaryAddress?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
)
}
@@ -0,0 +1,60 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
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
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed class ContactsUiState {
data object Loading : ContactsUiState()
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
}
class ContactsViewModel(
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
.map { ContactsUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactsUiState.Loading
)
fun createContact(
name: String,
addressType: PaymentAddressType?,
address: String?
) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
repo.createContact(
localAlias = name.trim(),
lnAddress = lnAddress,
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
)
}
}
fun deleteContact(id: String) {
viewModelScope.launch {
repo.deleteContact(id)
}
}
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactsViewModel(repo) as T
}
}
@@ -0,0 +1,160 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateContactSheet(
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
onDismiss: () -> Unit,
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
initialAddress: String? = null
) {
val strings = LocalAppStrings.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
var name by remember { mutableStateOf("") }
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf(initialAddress ?: "") }
val nameFocus = remember { FocusRequester() }
val canSave = name.isNotBlank()
val addressLabel = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
PaymentAddressType.LNURL -> "LNURL"
else -> "Address"
}
val addressPlaceholder = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
PaymentAddressType.LNURL -> "LNURL1..."
else -> ""
}
LaunchedEffect(Unit) { nameFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New contact",
style = MaterialTheme.typography.titleMedium
)
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(nameFocus)
)
// Address type toggle
Text(
text = "Payment address (optional)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = {
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
address = ""
},
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = {
selectedType = PaymentAddressType.LNURL
address = ""
},
label = { Text("LNURL") }
)
}
// Address field
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) {
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
}
}
),
modifier = Modifier.fillMaxWidth()
)
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(strings.cancel)
}
Button(
onClick = {
focusManager.clearFocus()
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
},
enabled = canSave
) {
Text("Save")
}
}
}
}
}
@@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -95,6 +96,17 @@ class FiatViewModel(
initialValue = null initialValue = null
) )
/** Combined fiat stream for [PaymentFeedRepository] feed assembly. */
val fiatFlow: StateFlow<Pair<String?, Double?>> = combine(
selectedCurrency,
fiatSatsPerUnit
) { currency, rate -> Pair(currency, rate) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = Pair(null, null)
)
// ── Called by BalanceViewModel after every successful balance refresh ───── // ── Called by BalanceViewModel after every successful balance refresh ─────
fun onBalanceRefreshed() { fun onBalanceRefreshed() {
viewModelScope.launch { fetchAndApplyFiatRate() } viewModelScope.launch { fetchAndApplyFiatRate() }
@@ -56,45 +56,47 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import com.bitcointxoko.gudariwallet.util.formatEpochShort import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── List screen ────────────────────────────────────────────────────────────── // ── List screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
vm : HistoryViewModel, vm : HistoryViewModel,
onClose : () -> Unit, onClose : () -> Unit,
onPaymentClick : (PaymentRecord) -> Unit onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val state by vm.filteredState.collectAsState() val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
val filter by vm.filter.collectAsState() val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
val fiatCurrency by vm.fiatCurrency.collectAsState() val filter by vm.filter.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val availableTypes by vm.availableTypes.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) } // fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
val dismissFilterSheet = { filterSheetVisible = false } // they are now embedded in each PaymentFeedItem
var searchVisible by rememberSaveable { mutableStateOf(false) }
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchInfoSheetVisible by remember { mutableStateOf(false) } var searchInfoSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.isActive val isFiltered = filter.isActive
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(strings.history.historyTitle) }, // ← "History" title = { Text(strings.history.historyTitle) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.historyClose // ← "Close" contentDescription = strings.history.historyClose
) )
} }
}, },
@@ -108,7 +110,7 @@ fun HistoryScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = strings.history.historySearchPayments // ← "Search payments" contentDescription = strings.history.historySearchPayments
) )
} }
} }
@@ -118,7 +120,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) { IconButton(onClick = { filterSheetVisible = true }) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = strings.history.historyFilterPayments // ← "Filter payments" contentDescription = strings.history.historyFilterPayments
) )
} }
} }
@@ -139,7 +141,7 @@ fun HistoryScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// ── Search bar ─────────────────────────────────────────────────── // ── Search bar ─────────────────────────────────────────────────
if (searchVisible) { if (searchVisible) {
SearchBar( SearchBar(
query = filter.searchQuery, query = filter.searchQuery,
@@ -153,7 +155,7 @@ fun HistoryScreen(
HorizontalDivider() HorizontalDivider()
} }
// ── Active filter chips ────────────────────────────────────────── // ── Active filter chips ────────────────────────────────────────
val searchActive = filter.searchQuery.isNotBlank() val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) { if (isFiltered || searchActive) {
ActiveFilterChips( ActiveFilterChips(
@@ -171,14 +173,84 @@ fun HistoryScreen(
) )
} }
// ── Content ────────────────────────────────────────────────────── // ── Content ────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> { // ── List — always rendered when state is Success, never unmounted ──────
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) val s = state
if (s is HistoryState.Success) {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
} }
if (items.isEmpty()) {
Text(
text = strings.history.historyNoPayments,
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = items,
key = { it.paymentHash },
contentType = { "payment" }
) { item ->
PaymentRow(
item = item,
onClick = { onPaymentClick(item) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item(contentType = "loader") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
}
}
}
// ── State overlays — sit on top of the list, never destroy it ─────────
when (val s = state) {
is HistoryState.Loading -> {
// Only show the full-screen spinner on the very first load
// (when we have no items yet). During loadMore(), the in-list
// footer spinner handles it — don't obscure the results.
if (items.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
is HistoryState.Error -> { is HistoryState.Error -> {
Column( Column(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
@@ -191,95 +263,33 @@ fun HistoryScreen(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Button(onClick = { vm.refresh() }) {
Text(strings.history.historyRetry) // ← "Retry" Text(strings.history.historyRetry)
}
}
}
is HistoryState.Success -> {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
Text(
text = strings.history.historyNoPayments, // ← "No payments yet."
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = s.payments,
key = { it.paymentHash },
contentType = { "payment" }
) { payment ->
PaymentRow(
payment = payment,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
onClick = { onPaymentClick(payment) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item(contentType = "loader") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
} }
} }
} }
is HistoryState.Success -> { /* handled above */ }
} }
} }
} }
} }
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
if (filterSheetVisible) { if (filterSheetVisible) {
FilterBottomSheet( FilterBottomSheet(
currentFilter = filter, currentFilter = filter,
availableTypes = availableTypes, availableTypes = availableTypes,
sheetState = sheetState, sheetState = sheetState,
onDismiss = dismissFilterSheet, onDismiss = dismissFilterSheet,
onDirectionSelected = { direction -> onDirectionSelected = { direction ->
vm.setDirectionFilter(direction) vm.setDirectionFilter(direction)
dismissFilterSheet() dismissFilterSheet()
}, },
onStatusToggled = { vm.toggleStatusFilter(it) }, onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) }, onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }, onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
onDatePresetSelected = { vm.applyDatePreset(it) }, onDatePresetSelected = { vm.applyDatePreset(it) },
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) }, onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
onDateClearRequested = { vm.clearDateFilter() } onDateClearRequested = { vm.clearDateFilter() }
) )
} }
@@ -289,7 +299,7 @@ fun HistoryScreen(
) )
} }
// ── Search bar ─────────────────────────────────────────────────────────────── // ── Search bar ───────────────────────────────────────────────────────────────
@Composable @Composable
private fun SearchBar( private fun SearchBar(
query : String, query : String,
@@ -310,13 +320,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) { IconButton(onClick = onInfoClick) {
Icon( Icon(
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
contentDescription = strings.history.historySearchHelp // ← "Search help" contentDescription = strings.history.historySearchHelp
) )
} }
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions" placeholder = { Text(strings.history.historySearchPlaceholder) },
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -337,7 +347,7 @@ private fun SearchBar(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ContentPaste, imageVector = Icons.Default.ContentPaste,
contentDescription = strings.history.historySearchPaste // ← "Paste" contentDescription = strings.history.historySearchPaste
) )
} }
IconButton( IconButton(
@@ -346,7 +356,7 @@ private fun SearchBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.historySearchClear, // ← "Clear search" contentDescription = strings.history.historySearchClear,
tint = if (query.isNotEmpty()) LocalContentColor.current tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
) )
@@ -354,22 +364,22 @@ private fun SearchBar(
} }
} }
// ── Active filter chips ────────────────────────────────────────────────────── // ── Active filter chips ──────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ActiveFilterChips( private fun ActiveFilterChips(
filter : PaymentFilter, filter : PaymentFilter,
searchActive : Boolean, searchActive : Boolean,
onClearDirection: () -> Unit, onClearDirection : () -> Unit,
onClearStatus : (StatusFilter) -> Unit, onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit, onClearType : (String) -> Unit,
onClearAmount : () -> Unit, onClearAmount : () -> Unit,
onClearDate : () -> Unit, onClearDate : () -> Unit,
onClearSearch : () -> Unit onClearSearch : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
FlowRow( FlowRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -382,15 +392,15 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (filter.direction) { when (filter.direction) {
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
else -> strings.history.filterDirectionIncoming // ← "Incoming" else -> strings.history.filterDirectionIncoming
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter" contentDescription = strings.history.filterClearDirection,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -403,16 +413,16 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (s) { when (s) {
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed" StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending" StatusFilter.PENDING -> strings.history.filterStatusPending
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed" StatusFilter.FAILED -> strings.history.filterStatusFailed
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter" contentDescription = strings.history.filterRemoveStatus,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -426,7 +436,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.filterRemoveType, // ← "Remove type filter" contentDescription = strings.history.filterRemoveType,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -435,11 +445,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) { if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when { val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null -> filter.minAmountSat != null && filter.maxAmountSat != null ->
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats" strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
filter.minAmountSat != null -> filter.minAmountSat != null ->
strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats" strings.history.filterAmountMin(filter.minAmountSat)
else -> else ->
strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats" strings.history.filterAmountMax(filter.maxAmountSat!!)
} }
InputChip( InputChip(
selected = true, selected = true,
@@ -448,7 +458,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter" contentDescription = strings.history.filterClearAmount,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -458,19 +468,23 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) { if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) { val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week" DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month" DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year" DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.history.filterDateRange( // ← "XY" strings.history.filterDateRange(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday), formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday) formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
) )
minCreatedAt != null -> minCreatedAt != null ->
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X" strings.history.filterDateFrom(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
)
else -> else ->
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X" strings.history.filterDateUntil(
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
)
} }
} }
InputChip( InputChip(
@@ -480,7 +494,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.filterClearDate, // ← "Clear date filter" contentDescription = strings.history.filterClearDate,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -490,11 +504,11 @@ private fun ActiveFilterChips(
InputChip( InputChip(
selected = true, selected = true,
onClick = onClearSearch, onClick = onClearSearch,
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX" label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.history.historyClearSearch, // ← "Clear search" contentDescription = strings.history.historyClearSearch,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -5,198 +5,464 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.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.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel" private const val TAG = "HistoryViewModel"
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class HistoryViewModel( class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>, private val contactRepo : ContactRepository,
val fiatSatsPerUnit : StateFlow<Double?> private val feedRepository : PaymentFeedRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModel() { ) : ViewModel() {
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
// ── Sync manager ──────────────────────────────────────────────────────────
// Owns the websocket/sync loop, pagination, and optimistic inserts.
// Still operates on PaymentRecord internally — untouched by this refactor.
private val syncManager: PaymentSyncManager = PaymentSyncManager( private val syncManager: PaymentSyncManager = PaymentSyncManager(
repo = repo, repo = repo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope, scope = viewModelScope,
onNewPayments = { payments -> enricher.enrich(payments) } onNewPayments = { payments -> enricher.enrich(payments) }
) )
// ── Optimistic insert (called after a successful send) ───────────────────── // ── Optimistic insert (called after a successful send) ─────────────────────
fun primePayment(record: PaymentRecord) { fun primePayment(record: PaymentRecord) {
syncManager.primePayment(record) syncManager.primePayment(record)
} }
// ── Enricher ──────────────────────────────────────────────────────────────
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
// which feeds into the feedRepository.observeFeedItems() combine.
private val enricher: PaymentEnricher = PaymentEnricher( private val enricher: PaymentEnricher = PaymentEnricher(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope, scope = viewModelScope,
onEnriched = { paymentHash, pubkey, alias -> onEnriched = { paymentHash, pubkey, alias ->
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias) syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
} }
) )
// ── Sync status ───────────────────────────────────────────────────────────
// Exposes Loading / Error / Success shell (isRefreshing, canLoadMore).
// Does NOT carry payment data — see feedItems below.
val state: StateFlow<HistoryState> = syncManager.state val state: StateFlow<HistoryState> = syncManager.state
fun refresh() = syncManager.refresh()
fun refresh() = syncManager.refresh()
fun loadMore() = syncManager.loadMore( fun loadMore() = syncManager.loadMore(
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true } filteredCanLoadMore = {
(state.value as? HistoryState.Success)?.canLoadMore == true
}
) )
// ── Detail delegate ─────────────────────────────────────────────────────── // ── Detail delegate ───────────────────────────────────────────────────────
private val detailDelegate = PaymentDetailDelegate( private val detailDelegate = PaymentDetailDelegate(
repo = repo, repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope scope = viewModelScope
) )
val detailState: StateFlow<DetailState> = detailDelegate.state val detailState: StateFlow<DetailState> = detailDelegate.state
fun loadDetail(checkingId: String, record: PaymentRecord? = null) = fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
detailDelegate.load(checkingId, record) detailDelegate.load(paymentHash, record)
fun clearDetail() = detailDelegate.clear() fun clearDetail() = detailDelegate.clear()
// ── Filter state ────────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter()) private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter val filter: StateFlow<PaymentFilter> = _filter
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } } fun setSearchQuery(q: String) {
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } } _filter.update { it.copy(searchQuery = q.trim()) }
fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) }
} }
fun toggleStatusFilter(status: StatusFilter) {
fun clearSearch() {
_filter.update { it.copy(searchQuery = "") }
}
fun setDirectionFilter(d: DirectionFilter) {
_filter.update { it.copy(direction = d) }
}
fun toggleStatusFilter(s: StatusFilter) {
_filter.update { f -> _filter.update { f ->
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status val updated = if (s in f.statuses) f.statuses - s else f.statuses + s
f.copy(statuses = updated) f.copy(statuses = updated)
} }
} }
fun toggleTypeFilter(type: String) { fun toggleTypeFilter(type: String) {
_filter.update { f -> _filter.update { f ->
val updated = if (type in f.types) f.types - type else f.types + type val updated = if (type in f.types) f.types - type else f.types + type
f.copy(types = updated) f.copy(types = updated)
} }
} }
fun setAmountFilter(min: Long?, max: Long?) { fun setAmountFilter(min: Long?, max: Long?) {
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) } _filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
} }
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) { fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) } _filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
} }
fun applyDatePreset(preset: DatePreset) { fun applyDatePreset(preset: DatePreset) {
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt val (min, max) = preset.toEpochSecondRange()
setDateFilter(min, max, preset) setDateFilter(min, max, preset)
} }
fun clearDateFilter() { fun clearDateFilter() {
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) } _filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
} }
fun findPayment(checkingId: String): PaymentRecord? { fun toggleContactFilter(contactId: String) {
val fromFiltered = (filteredState.value as? HistoryState.Success) _filter.update { f ->
?.payments?.firstOrNull { it.checkingId == checkingId } val updated = if (contactId in f.contactIds)
if (fromFiltered != null) return fromFiltered f.contactIds - contactId else f.contactIds + contactId
return (state.value as? HistoryState.Success) f.copy(contactIds = updated)
?.payments?.firstOrNull { it.checkingId == checkingId } }
}
fun clearContactFilter() {
_filter.update { it.copy(contactIds = emptySet()) }
} }
// ── Room-backed query results ────────────────────────────────────────────── // ── Room-backed query results ──────────────────────────────────────────────
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null) // Null = use the live feed stream.
// Non-null = a search / contact-ID Room query has run and its results
// override the live stream until the filter is cleared.
private val _roomResults = MutableStateFlow<List<PaymentFeedItem>?>(null)
// ── Derived state ───────────────────────────────────────────────────────── // ── Feed items — single source of truth for the UI list ───────────────────
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
/** Distinct non-null tags present in the currently loaded list. */ // source changes: payments DB, contact links, alias cache, fiat rate,
val availableTypes: StateFlow<List<String>> = syncManager.state // active filter, or Room query override.
.map { s -> val feedItems: StateFlow<List<PaymentFeedItem>> = combine(
if (s !is HistoryState.Success) emptyList() feedRepository.observeFeedItems(),
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted() _filter,
_roomResults
) { feed, f, roomResults ->
Triple(feed, f, roomResults)
}
.distinctUntilChanged { old, new ->
// If room results are active on both sides and haven't changed, suppress
val oldRoom = old.third
val newRoom = new.third
if (oldRoom != null && newRoom != null && oldRoom === newRoom && old.second == new.second) {
return@distinctUntilChanged true // same room results, same filter — skip
}
false // otherwise let through
} }
.map { (feed, f, roomResults) ->
withContext(Dispatchers.Default) {
roomResults?.applyFeedFilters(
f.copy(searchQuery = "", contactIds = emptySet())
) ?: feed.applyFeedFilters(f)
}
}
.distinctUntilChanged()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults -> // ── Available type filter chips ────────────────────────────────────────────
if (s !is HistoryState.Success) return@combine s val availableTypes: StateFlow<List<String>> = feedItems
if (roomResults == null) { .map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
s.copy(payments = s.payments.applyInMemoryFilters(f)) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
} else {
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
// ── In-memory filter logic ──────────────────────────────────────────────── // ── Find a single item by paymentHash ─────────────────────────────────────
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) = // paymentHash supersedes checkingId as the stable unique identifier.
// Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip.
fun findPayment(paymentHash: String): PaymentFeedItem? =
feedItems.value.firstOrNull { it.paymentHash == paymentHash }
// ── In-memory filter logic ─────────────────────────────────────────────────
// All filter keys map directly to PaymentFeedItem fields — no extra
// resolution needed (contact names, alias, fiat are already embedded).
private fun List<PaymentFeedItem>.applyInMemoryFilters(f: PaymentFilter): List<PaymentFeedItem> =
distinctBy { it.paymentHash } distinctBy { it.paymentHash }
.let { list -> .let { list ->
when (f.direction) { when (f.direction) {
DirectionFilter.ALL -> list DirectionFilter.ALL -> list
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing } DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
} }
} }
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { item ->
f.statuses.any { status ->
when (status) {
StatusFilter.PENDING -> item.isPending
StatusFilter.FAILED -> item.isFailed
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
}
}
}
}
.let { list -> .let { list ->
if (f.types.isEmpty()) list if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types } else list.filter { it.tag in f.types }
}
.let { list ->
val min = f.minAmountSat;
val max = f.maxAmountSat
if (min == null && max == null) list
else list.filter { item ->
(min == null || item.amountSat >= min) &&
(max == null || item.amountSat <= max)
}
}
.let { list ->
val min = f.minCreatedAt;
val max = f.maxCreatedAt
if (min == null && max == null) list
else list.filter { item ->
val ts = item.createdAt ?: item.time
ts != null &&
(min == null || ts >= min) &&
(max == null || ts <= max)
}
}
.let { list ->
val q = f.searchQuery
if (q.isBlank()) list
else list.filter { item ->
item.memo?.contains(q, ignoreCase = true) == true ||
item.contactName?.contains(q, ignoreCase = true) == true ||
item.paymentHash.contains(q, ignoreCase = true)
}
} }
// ── Init ────────────────────────────────────────────────────────────────── // ── 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 ->
contacts.associate { c ->
c.contact.id to (c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: "Unknown")
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()
)
// ── Per-payment contact queries ────────────────────────────────────────────
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
contactRepo.observeContactsForPayment(paymentHash)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
contactRepo.observeAllContacts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ── Contact assignment ─────────────────────────────────────────────────────
fun assignContact(paymentHash: String, contactId: String) {
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) }
}
fun saveContactAndAssign(
paymentHash: String,
name: String,
addressType: PaymentAddressType?,
address: String?
) {
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
val contact = contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
contactRepo.linkPaymentToContact(paymentHash, contact.id)
}
}
// ── Init ───────────────────────────────────────────────────────────────────
init { init {
syncManager.init() syncManager.init()
// ── Room query path ────────────────────────────────────────────────────
// Activated when the filter requires DB-level text search or contact-ID
// lookup. Results are stored in _roomResults and override the live feed
// stream in feedItems. Cleared back to null when no Room query is needed.
viewModelScope.launch { viewModelScope.launch {
combine( combine(
_filter.map { it.searchQuery }.debounce(300.milliseconds), _filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter _filter
) { _, f -> f } ) { _, f -> f }
.distinctUntilChanged()
.collect { f -> .collect { f ->
if (!f.needsRoomQuery()) { if (!f.needsRoomQuery()) {
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
_roomResults.value = null _roomResults.value = null
} else { return@collect
Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"statuses=${f.statuses} dir=${f.direction} types=${f.types} ")
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
} }
// Snapshot the contact summaries once per query — no new DAO needed.
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
// resolve contact names without per-row DB calls.
val contactSnapshot: Map<String, List<ContactSummary>> =
contactRepo.observePaymentContactSummaries()
.first()
.groupBy(
keySelector = { it.paymentHash },
valueTransform = {
ContactSummary(
id = it.contactId,
displayName = it.displayName
)
}
)
// ── Explicit contact-ID filter (highest priority) ──────────
if (f.contactIds.isNotEmpty()) {
val contactHashes = contactRepo
.findPaymentHashesByContactIds(f.contactIds.toList())
_roomResults.value = if (contactHashes.isNotEmpty()) {
paymentCache.queryByPaymentHashes(contactHashes, f)
.map { it.toFeedItem(contactSnapshot) }
.also {
Timber.tag(TAG).d(
"FILTER [ROOM+CONTACT] contacts=${f.contactIds}${it.size} results"
)
}
} else {
emptyList()
}
return@collect
}
// ── Text search ────────────────────────────────────────────
val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList()
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 { viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event -> WalletNotificationService.paymentEvents.collect { event ->
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list") Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
syncManager.refresh() syncManager.refresh()
} }
} }
} }
// ── Private helper ─────────────────────────────────────────────────────────
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
// Contact links, alias, and fiat are intentionally left empty/null here —
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
// for all items that pass through the live feed path.
private fun PaymentRecord.toFeedItem(
contactSnapshot: Map<String, List<ContactSummary>> = emptyMap()
): PaymentFeedItem =
this.toPaymentFeedItem(
linkedContacts = contactSnapshot[paymentHash] ?: emptyList(),
resolvedAlias = pubkey?.let { aliasRepo.aliases.value[it] },
fiatCurrency = fiatCurrency.value,
fiatSatsPerUnit = fiatSatsPerUnit.value
)
} }
// ── Factory ─────────────────────────────────────────────────────────────────── // ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory( class HistoryViewModelFactory(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val fiatCurrency : StateFlow<String?>, private val contactRepo : ContactRepository,
private val fiatSatsPerUnit: StateFlow<Double?> private val feedRepository : PaymentFeedRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
"Unknown ViewModel class: ${modelClass.name}"
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return HistoryViewModel( return HistoryViewModel(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
contactRepo = contactRepo,
feedRepository = feedRepository,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit
) as T ) as T
} }
} }
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
class PaymentDetailDelegate( class PaymentDetailDelegate(
private val repo : WalletRepository, private val repo : WalletRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val aliasRepo : NodeAliasRepository,
private val scope : CoroutineScope private val scope : CoroutineScope
) { ) {
private val _state = MutableStateFlow<DetailState>(DetailState.Idle) private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
fun load(checkingId: String, record: PaymentRecord? = null) { fun load(checkingId: String, record: PaymentRecord? = null) {
paymentCache.getCachedDetail(checkingId)?.let { paymentCache.getCachedDetail(checkingId)?.let {
Timber.d("DETAIL [HIT] $checkingId — instant from cache") Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache")
_state.value = DetailState.Success(it) _state.value = DetailState.Success(it)
return return
} }
_state.value = if (record != null) { _state.value = if (record != null) {
Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord") Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
DetailState.Partial(record) DetailState.Partial(record)
} else { } else {
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner") Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
DetailState.Loading DetailState.Loading
} }
scope.launch { scope.launch {
val fromDb = paymentCache.getDetailFromDb(checkingId) val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) { if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb) val record = fromDb.details
_state.value = DetailState.Success(fromDb) val enriched = if (
Timber.d("DETAIL [DB] $checkingId — served from Room") record != null &&
record.pubkey.isNullOrBlank() &&
!record.bolt11.isNullOrBlank()
) {
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey")
val pubkey = runCatching {
repo.decodeBolt11(record.bolt11!!).payee
}.getOrNull()
if (pubkey != null) {
val alias = runCatching {
aliasRepo.resolve(pubkey)
}.getOrNull()
paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias)
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias")
fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias))
} else {
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed")
fromDb
}
} else {
fromDb
}
paymentCache.saveDetail(checkingId, enriched)
_state.value = DetailState.Success(enriched)
Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room")
return@launch return@launch
} }
runCatching { repo.getPaymentDetail(checkingId) } runCatching { repo.getPaymentDetail(checkingId) }
.onSuccess { detail -> .onSuccess { detail ->
Timber.d("DETAIL [NET] $checkingId — fetched from network") Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
paymentCache.saveDetail(checkingId, detail) paymentCache.saveDetail(checkingId, detail)
_state.value = DetailState.Success(detail) _state.value = DetailState.Success(detail)
} }
.onFailure { e -> .onFailure { e ->
Timber.w("DETAIL [ERR] $checkingId${e.message}") Timber.tag(TAG).w("DETAIL [ERR] $checkingId${e.message}")
if (_state.value !is DetailState.Partial) { if (_state.value !is DetailState.Partial) {
_state.value = DetailState.Error(e.message ?: "Failed to load payment") _state.value = DetailState.Error(e.message ?: "Failed to load payment")
} }
} }
} }
} }
fun clear() { _state.value = DetailState.Idle } fun clear() { _state.value = DetailState.Idle }
} }
@@ -6,23 +6,30 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward 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.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,8 +39,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
@@ -41,49 +50,88 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.ContactAvatar
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.ui.common.StatusChip import com.bitcointxoko.gudariwallet.ui.common.StatusChip
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Detail screen ──────────────────────────────────────────────────────────── // ── Detail screen ────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PaymentDetailScreen( fun PaymentDetailScreen(
payment : PaymentRecord, item : PaymentFeedItem, // ← was: payment: PaymentRecord
vm : HistoryViewModel, vm : HistoryViewModel,
onBack : () -> Unit onBack : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState() val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) } // fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
// no need to collect them from the ViewModel separately.
val fiatCurrency = item.fiatCurrency
val fiatSatsPerUnit = item.fiatSatsPerUnit
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
DisposableEffect(Unit) { onDispose { vm.clearDetail() } } DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
// Enrich the base PaymentFeedItem's backing record with the detail response.
// PaymentDetailContent still operates on PaymentRecord for technical fields.
// enriched falls back through partial → base PaymentRecord via toDomain()
// if the detail fetch is still in flight or has failed.
val enriched: PaymentRecord = when (val s = detailState) { val enriched: PaymentRecord = when (val s = detailState) {
is DetailState.Success -> { is DetailState.Success -> {
val detail = s.detail.details val detail = s.detail.details
when { when {
detail == null -> payment detail == null -> item.toBaseRecord()
else -> detail.copy( else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11 bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
) )
} }
} }
is DetailState.Partial -> s.record is DetailState.Partial -> s.record.let { r ->
else -> payment r.copy(
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
)
}
else -> item.toBaseRecord()
}
// ── Contact state ──────────────────────────────────────────────────────────
val linkedContacts by remember(item.paymentHash) {
vm.contactsForPayment(item.paymentHash)
}.collectAsStateWithLifecycle()
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
var showContactPicker by remember { mutableStateOf(false) }
var showCreateContact by remember { mutableStateOf(false) }
// Best-effort lnAddress to pre-fill CreateContactSheet
val paymentLnAddress = remember(enriched) {
enriched.extra?.comment
?.takeIf { it.contains("@") && !it.contains(" ") }
} }
Scaffold( Scaffold(
@@ -91,15 +139,15 @@ fun PaymentDetailScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
if (payment.isOutgoing) strings.history.detailOutgoingTitle // ← "Outgoing payment" if (item.isOutgoing) strings.history.detailOutgoingTitle
else strings.history.detailIncomingTitle // ← "Incoming payment" else strings.history.detailIncomingTitle
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.history.detailBack // ← "Back" contentDescription = strings.history.detailBack
) )
} }
} }
@@ -121,7 +169,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = strings.history.detailCouldNotLoad, // ← "Could not load full details" text = strings.history.detailCouldNotLoad,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
@@ -130,34 +178,86 @@ fun PaymentDetailScreen(
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(item.paymentHash, contactId)
}
) )
} }
} }
else -> { else -> {
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
modifier = Modifier.padding(padding) linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(item.paymentHash, contactId)
},
modifier = Modifier.padding(padding)
) )
} }
} }
} }
// ── Contact picker sheet ───────────────────────────────────────────────────
if (showContactPicker) {
ContactPickerSheet(
contacts = allContacts,
onContactSelected = { contactId ->
vm.assignContact(item.paymentHash, contactId)
showContactPicker = false
},
onCreateNew = {
showContactPicker = false
showCreateContact = true
},
onDismiss = { showContactPicker = false }
)
}
// ── Create & assign contact sheet ──────────────────────────────────────────
if (showCreateContact) {
val addressType = if (paymentLnAddress != null)
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
CreateContactSheet(
initialAddressType = addressType,
initialAddress = paymentLnAddress,
onSave = { name, type, address ->
vm.saveContactAndAssign(item.paymentHash, name, type, address)
showCreateContact = false
},
onDismiss = { showCreateContact = false }
)
}
} }
// ── Detail content ─────────────────────────────────────────────────────────── // ── Detail content ─────────────────────────────────────────────────────────────
// Receives the enriched PaymentRecord from detailState — all technical fields
// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here.
// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item).
@Composable @Composable
private fun PaymentDetailContent( private fun PaymentDetailContent(
payment : PaymentRecord, payment : PaymentRecord,
fiatCurrency : String?, fiatCurrency : String?,
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(
isPending = payment.pending,
isFailed = payment.status == "failed",
isOutgoing = payment.isOutgoing
)
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
@@ -183,7 +283,7 @@ private fun PaymentDetailContent(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ── Hero amount ────────────────────────────────────────────────────── // ── Hero amount ────────────────────────────────────────────────────────
item { item {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -192,7 +292,7 @@ private fun PaymentDetailContent(
) { ) {
Icon( Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward, else Icons.Default.ArrowDownward,
contentDescription = null, contentDescription = null,
tint = amountColor, tint = amountColor,
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
@@ -207,16 +307,13 @@ private fun PaymentDetailContent(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) )
if (payment.feeSat > 0) { if (payment.feeSat > 0) {
val ppm = feePpm( val ppm = feePpm(
amountMsat = kotlin.math.abs(payment.amountMsat), amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat) feeMsat = kotlin.math.abs(payment.feeMsat)
) )
// Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else "" val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else "" val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.history.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm" val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
payment.feeSat, fiatPart, ppmPart
)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = feeLabel, text = feeLabel,
@@ -230,43 +327,129 @@ private fun PaymentDetailContent(
} }
} }
// ── Basic info ─────────────────────────────────────────────────────── // ── Contact section ────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details" if (linkedContacts.isEmpty()) {
DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date" OutlinedButton(
payment.createdAt, onClick = onAssignContact,
payment.time, modifier = Modifier.fillMaxWidth()
strings.today, ) {
strings.yesterday)) 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(28.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
// contentDescription = strings.history.detailRemoveContact,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (index < linkedContacts.lastIndex) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
// ── Basic info ─────────────────────────────────────────────────────────
item {
DetailSection(title = strings.history.detailSectionDetails) {
DetailRow( DetailRow(
strings.history.detailRowMemo, // ← "Memo" strings.history.detailRowDate,
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
)
DetailRow(
strings.history.detailRowMemo,
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
) )
if (payment.extra?.tag != null) { if (payment.extra?.tag != null) {
DetailRow(strings.history.detailRowType, payment.extra.tag) // ← "Type" DetailRow(strings.history.detailRowType, payment.extra.tag)
} }
if (payment.extra?.comment?.isNotBlank() == true) { if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow(strings.history.detailRowComment, payment.extra.comment) // ← "Comment" DetailRow(strings.history.detailRowComment, payment.extra.comment)
} }
} }
} }
// ── Success action (LNURL) ─────────────────────────────────────────── // ── Success action (LNURL) ─────────────────────────────────────────────
payment.extra?.successAction?.let { action -> payment.extra?.successAction?.let { action ->
item { item {
DetailSection(title = strings.history.detailSectionSuccessAction) { // ← "Success Action" DetailSection(title = strings.history.detailSectionSuccessAction) {
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } // ← "Message" action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } // ← "URL" action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } // ← "Description" action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
} }
} }
} }
// ── Technical ──────────────────────────────────────────────────────── // ── Technical ────────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionTechnical) { // ← "Technical" DetailSection(title = strings.history.detailSectionTechnical) {
DetailRow( DetailRow(
label = strings.history.detailRowPaymentHash, // ← "Payment Hash" label = strings.history.detailRowPaymentHash,
value = payment.paymentHash, value = payment.paymentHash,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -280,7 +463,7 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = strings.history.detailRowPreimage, // ← "Preimage" label = strings.history.detailRowPreimage,
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -297,11 +480,11 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = strings.history.detailRowDestination, // ← "Destination" label = strings.history.detailRowDestination,
value = label, value = label,
valueColor = MaterialTheme.colorScheme.primary, valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = { onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}, },
trailingContent = { trailingContent = {
@@ -311,7 +494,7 @@ private fun PaymentDetailContent(
} }
if (!payment.bolt11.isNullOrBlank()) { if (!payment.bolt11.isNullOrBlank()) {
DetailRow( DetailRow(
label = strings.history.detailRowBolt11, // ← "BOLT11" label = strings.history.detailRowBolt11,
value = payment.bolt11, value = payment.bolt11,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.history package com.bitcointxoko.gudariwallet.ui.history
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@@ -14,7 +15,8 @@ data class PaymentFilter(
val maxAmountSat : Long? = null, val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null, val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null, val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null val datePreset : DatePreset? = null,
val contactIds : Set<String> = emptySet()
) )
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
@@ -29,12 +31,16 @@ val PaymentFilter.isActive: Boolean
|| maxAmountSat != null || maxAmountSat != null
|| minCreatedAt != null || minCreatedAt != null
|| maxCreatedAt != null || maxCreatedAt != null
|| contactIds.isNotEmpty()
fun PaymentFilter.needsRoomQuery(): Boolean = fun PaymentFilter.needsRoomQuery(): Boolean =
searchQuery.isNotBlank() || searchQuery.isNotBlank() ||
statuses.isNotEmpty() || statuses.isNotEmpty() ||
minAmountSat != null || maxAmountSat != null || minAmountSat != null ||
minCreatedAt != null || maxCreatedAt != null maxAmountSat != null ||
minCreatedAt != null ||
maxCreatedAt != null ||
contactIds.isNotEmpty()
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */ /** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> { fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
@@ -60,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
if (f.types.isEmpty()) list if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { record ->
f.statuses.any { status ->
when (status) {
StatusFilter.COMPLETED -> !record.pending && record.status == "complete"
StatusFilter.PENDING -> record.pending
StatusFilter.FAILED -> record.status == "failed"
}
}
}
}
fun List<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
distinctBy { it.paymentHash }
.let { list ->
when (f.direction) {
DirectionFilter.ALL -> list
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
}
}
.let { list ->
if (f.types.isEmpty()) list
else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization
}
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { item ->
f.statuses.any { status ->
when (status) {
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
StatusFilter.PENDING -> item.isPending
StatusFilter.FAILED -> item.isFailed
}
}
}
}
.let { list ->
if (f.searchQuery.isBlank()) list
else list.filter { item ->
item.memo?.contains(f.searchQuery, ignoreCase = true) == true
|| item.contactName?.contains(f.searchQuery, ignoreCase = true) == true
|| item.alias?.contains(f.searchQuery, ignoreCase = true) == true
|| item.paymentHash.startsWith(f.searchQuery, ignoreCase = true)
}
}
@@ -22,33 +22,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp import com.bitcointxoko.gudariwallet.util.formatTimestamp
import com.bitcointxoko.gudariwallet.util.isFailedStatus
import com.bitcointxoko.gudariwallet.util.isPendingStatus
@Composable @Composable
internal fun PaymentRow( internal fun PaymentRow(
payment : PaymentRecord, item : PaymentFeedItem,
fiatCurrency : String?, onClick : () -> Unit
fiatSatsPerUnit : Double?,
onClick : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val isPending = isPendingStatus(payment.status) val semantic = semanticColors()
val isFailed = isFailedStatus(payment.status)
val semantic = semanticColors()
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) { val amountColor = paymentAmountColor(
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0) isPending = item.isPending,
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency) isFailed = item.isFailed,
isOutgoing = item.isOutgoing
)
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
else null else null
} }
@@ -60,45 +58,47 @@ internal fun PaymentRow(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent" contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
else strings.history.paymentRowReceivedCD, // ← "Received" else strings.history.paymentRowReceivedCD,
tint = amountColor, tint = amountColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } text = item.contactName // contact name first
?: strings.history.paymentRowNoMemo, // ← "No memo" ?: item.memo?.takeIf { it.isNotBlank() } // then memo
?: strings.history.paymentRowNoMemo, // then fallback
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) { val formattedTime = remember(item.createdAt ?: item.time) {
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday) formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
} }
Text( Text(
text = formattedTime, text = formattedTime,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val statusLabel = when { val statusLabel = when {
isPending -> strings.history.paymentRowPending item.isPending -> strings.history.paymentRowPending
isFailed -> strings.history.paymentRowFailed item.isFailed -> strings.history.paymentRowFailed
else -> null else -> null
} }
val statusColor = when { val statusColor = when {
isPending -> semantic.warningContainer item.isPending -> semantic.warningContainer
isFailed -> semantic.errorContainer item.isFailed -> semantic.errorContainer
else -> semantic.successContainer // fallback, won't be used else -> semantic.successContainer
} }
val statusContentColor = when { val statusContentColor = when {
isPending -> semantic.onWarningContainer item.isPending -> semantic.onWarningContainer
isFailed -> semantic.onErrorContainer item.isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer else -> semantic.onSuccessContainer
} }
if (statusLabel != null) { if (statusLabel != null) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -117,10 +117,25 @@ internal fun PaymentRow(
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
AmountWithFiatColumn( AmountWithFiatColumn(
amountSat = payment.amountSat, amountSat = item.amountSat,
isOutgoing = payment.isOutgoing, isOutgoing = item.isOutgoing,
amountColor = amountColor, amountColor = amountColor,
fiatLine = fiatLine fiatLine = fiatLine
) )
} }
} }
@Composable
internal fun paymentAmountColor(
isPending : Boolean,
isFailed : Boolean,
isOutgoing : Boolean
): androidx.compose.ui.graphics.Color {
val semantic = semanticColors()
return when {
isPending -> semantic.onWarningContainer
isFailed -> MaterialTheme.colorScheme.error
isOutgoing -> MaterialTheme.colorScheme.onSurface
else -> semantic.onSuccessContainer
}
}
@@ -15,14 +15,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.LockStrings
@Composable @Composable
fun BiometricLockScreen(onUnlocked: () -> Unit) { fun BiometricLockScreen(onUnlocked: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as FragmentActivity val activity = context as FragmentActivity
val strings = LocalAppStrings.current.lock
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
showBiometricPrompt(activity, onUnlocked) showBiometricPrompt(activity, onUnlocked, strings)
} }
Scaffold { innerPadding -> Scaffold { innerPadding ->
@@ -47,7 +50,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
text = "Gudari Wallet", text = strings.appName,
style = MaterialTheme.typography.displaySmall, style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -56,7 +59,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = "Authenticate to continue", text = strings.authenticateToContinue,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
@@ -65,7 +68,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
FilledTonalButton( FilledTonalButton(
onClick = { showBiometricPrompt(activity, onUnlocked) } onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Fingerprint, imageVector = Icons.Filled.Fingerprint,
@@ -73,7 +76,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
modifier = Modifier.size(ButtonDefaults.IconSize) modifier = Modifier.size(ButtonDefaults.IconSize)
) )
Spacer(Modifier.width(ButtonDefaults.IconSpacing)) Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text("Unlock") Text(strings.unlockButton)
} }
} }
} }
@@ -82,7 +85,8 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
private fun showBiometricPrompt( private fun showBiometricPrompt(
activity: FragmentActivity, activity: FragmentActivity,
onUnlocked: () -> Unit onUnlocked: () -> Unit,
strings: LockStrings
) { ) {
val executor = ContextCompat.getMainExecutor(activity) val executor = ContextCompat.getMainExecutor(activity)
@@ -95,8 +99,8 @@ private fun showBiometricPrompt(
val prompt = BiometricPrompt(activity, executor, callback) val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock Gudari Wallet") .setTitle(strings.biometricPromptTitle)
.setSubtitle("Authenticate to access your wallet") .setSubtitle(strings.biometricPromptSubtitle)
.setAllowedAuthenticators( .setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG BiometricManager.Authenticators.BIOMETRIC_STRONG
or BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.DEVICE_CREDENTIAL
@@ -9,12 +9,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
@Composable @Composable
fun BiometricLockPage( fun BiometricLockPage(
appLockEnabled: Boolean, appLockEnabled: Boolean,
onAppLockChanged: (Boolean) -> Unit onAppLockChanged: (Boolean) -> Unit
) { ) {
val strings = LocalAppStrings.current.lock
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -32,7 +34,7 @@ fun BiometricLockPage(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
text = "App Lock", text = strings.appLockTitle,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@@ -40,7 +42,7 @@ fun BiometricLockPage(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.", text = strings.appLockDescription,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -63,11 +65,12 @@ fun BiometricLockPage(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "Lock app on open", text = strings.appLockToggleLabel,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
text = if (appLockEnabled) "Enabled (recommended)" else "Disabled", text = if (appLockEnabled) strings.appLockToggleEnabled
else strings.appLockToggleDisabled,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
@@ -70,7 +71,7 @@ import kotlin.time.Duration.Companion.milliseconds
fun ReceiveScreen( fun ReceiveScreen(
vm : WalletViewModel, vm : WalletViewModel,
nfcVm : NfcViewModel, nfcVm : NfcViewModel,
onNavigateToPaymentDetail: (checkingId: String) -> Unit onNavigateToPaymentDetail : (paymentHash: String) -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val context = LocalContext.current val context = LocalContext.current
@@ -156,16 +157,21 @@ fun ReceiveScreen(
} }
is ReceiveState.PaymentReceived -> { is ReceiveState.PaymentReceived -> {
ReceiveSuccessContent( PaymentSuccessContent(
amountSats = state.amountSats, title = strings.receive.paymentReceived,
memo = state.memo, amountSats = state.amountSats,
fiatRate = fiatRate, fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
fiatCurrency = fiatCurrency, subLine = state.memo?.takeIf { it.isNotBlank() },
onDone = { vm.resetReceiveState() }, onDetails = { onNavigateToPaymentDetail(state.paymentHash) },
onDetails = { onNavigateToPaymentDetail(state.checkingId) } secondaryLabel = strings.done,
onSecondary = { vm.resetReceiveState() },
lnurl = null, // sender identity not available
onSaveContact = null,
) )
} }
is ReceiveState.Error -> { is ReceiveState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
@@ -722,51 +728,4 @@ private fun ReceiveInvoiceContent(
) )
} }
} }
} }
// ── ReceiveSuccessContent ─────────────────────────────────────────────────────
@Composable
private fun ReceiveSuccessContent(
amountSats : Long,
memo : String?,
fiatRate : Double?,
fiatCurrency : String?,
onDone : () -> Unit,
onDetails : () -> Unit
) {
val strings = LocalAppStrings.current
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(16.dp))
Text(
text = strings.receive.paymentReceived, // ← "Payment Received!"
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(memo, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(32.dp))
Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) {
Text(strings.details) // ← "Details"
}
Spacer(Modifier.height(32.dp))
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text(strings.done) // ← "Done"
}
}
}
@@ -36,7 +36,7 @@ class ReceiveViewModel(
_receiveState.value = ReceiveState.PaymentReceived( _receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats, amountSats = event.amountSats,
memo = event.memo, memo = event.memo,
checkingId = rs.paymentHash paymentHash = rs.paymentHash
) )
} }
} }
@@ -1,30 +1,53 @@
package com.bitcointxoko.gudariwallet.ui.send package com.bitcointxoko.gudariwallet.ui.send
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
import com.bitcointxoko.gudariwallet.util.payingToLabel import com.bitcointxoko.gudariwallet.util.payingToLabel
import com.bitcointxoko.gudariwallet.util.satsToFiat import com.bitcointxoko.gudariwallet.util.satsToFiat
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
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 @Composable
internal fun LnurlPayForm( internal fun LnurlPayForm(
@@ -32,82 +55,203 @@ internal fun LnurlPayForm(
vm : WalletViewModel, vm : WalletViewModel,
fiatRate : Double?, fiatRate : Double?,
fiatCurrency: String?, fiatCurrency: String?,
sendStrings: SendStrings sendStrings : SendStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
var amount by remember { mutableStateOf(state.minSats.toString()) } var amountText by remember { mutableStateOf(state.minSats.toString()) }
var comment by remember { mutableStateOf("") } var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats val isFixed = state.minSats == state.maxSats
// Long? — null means the field is empty or non-numeric, not zero. val showFiatToggle = fiatRate != null && fiatCurrency != null
val sats: Long? = amount.toLongOrNull() 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 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()
var showSaveContact by remember { mutableStateOf(false) }
// ── Derive address type directly from the lnurl string ───────────────
val addressType = remember(state.lnurl) {
when (SendInputDetector.detect(state.lnurl)) {
SendInputType.LightningAddress -> PaymentAddressType.LIGHTNING_ADDRESS
else -> PaymentAddressType.LNURL
}
}
// ── 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 { Column {
Text( // ── Recipient header ──────────────────────────────────────────────
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X" if (existingContact != null) {
style = MaterialTheme.typography.titleMedium val contactName = existingContact!!.localAlias
) ?: existingContact!!.displayName
?: existingContact!!.name
?: payingToLabel(state.lnurl, state.domain)
Text(
text = strings.payingTo(contactName),
style = MaterialTheme.typography.titleMedium
)
} else {
Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
style = MaterialTheme.typography.titleMedium
)
}
if (state.description.isNotBlank()) { if (state.description.isNotBlank()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text(state.description, style = MaterialTheme.typography.bodySmall) Text(state.description, style = MaterialTheme.typography.bodySmall)
} }
Spacer(Modifier.height(12.dp))
OutlinedTextField( // ── "Add to contacts" chip — only when no existing contact ───────
value = amount, if (existingContact == null) {
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
isError = amount.isNotBlank() && !isAmountValid,
supportingText = {
when {
// Error: typed something out of range
amount.isNotBlank() && !isAmountValid -> {
Text(
text = strings.lnurlAmountError( // ← "Enter an amount between X and Y sats"
state.minSats,
state.maxSats
),
color = MaterialTheme.colorScheme.error
)
}
// Fiat range hint when rate is available
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)) // ← "X Y EUR" (fixed amount)
else
Text( // ← "X Y sats · A B EUR"
strings.lnurlSatsAndFiatRange(
state.minSats, state.maxSats,
minFiat, maxFiat
)
)
}
// Sats-only range when no fiat rate
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
else -> {}
}
},
enabled = !isFixed,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( SuggestionChip(
value = comment, onClick = { showSaveContact = true },
onValueChange = { if (it.length <= state.commentAllowed) comment = it }, label = { Text("Add to contacts") },
label = { Text(strings.commentOptional(state.commentAllowed)) }, // ← "Comment (optional, max X chars)" icon = {
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) }, // ← "X/Y" Icon(
modifier = Modifier.fillMaxWidth() Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
}
) )
} }
// ── 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)) Spacer(Modifier.height(16.dp))
// ── Amount field with currency switcher ───────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
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()
)
}
Spacer(Modifier.height(16.dp))
// ── Pay button ────────────────────────────────────────────────────
Button( Button(
onClick = { onClick = {
if (sats != null && isAmountValid) { if (sats != null && isAmountValid) {
@@ -117,7 +261,8 @@ internal fun LnurlPayForm(
domain = state.domain, domain = state.domain,
amountMsat = sats * WalletConstants.MSAT_PER_SAT, amountMsat = sats * WalletConstants.MSAT_PER_SAT,
comment = comment.takeIf { it.isNotBlank() }, comment = comment.takeIf { it.isNotBlank() },
strings = sendStrings strings = sendStrings,
contactId = existingContact?.id
) )
} }
}, },
@@ -125,9 +270,22 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) // ← "Pay X sats · Y EUR" if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
else strings.lnurlPayButton(amount) // ← "Pay X sats" else strings.lnurlPayButton(satsLabelForButton)
) )
} }
} }
// ── CreateContactSheet — pre-filled with resolved address ─────────────
if (showSaveContact) {
CreateContactSheet(
initialAddressType = addressType,
initialAddress = state.lnurl,
onSave = { name, type, address ->
vm.saveContactFromSend(name, type, address)
showSaveContact = false
},
onDismiss = { showSaveContact = false }
)
}
} }
@@ -28,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
@@ -44,6 +45,7 @@ fun SendScreen(
vm : WalletViewModel, vm : WalletViewModel,
nfcVm : NfcViewModel, nfcVm : NfcViewModel,
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> }, onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> },
onViewContact: ((contactId: String) -> Unit)? = null,
sendStrings: SendStrings sendStrings: SendStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -59,6 +61,17 @@ fun SendScreen(
} }
val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency
// ── Consume address prefilled from Contacts tap-to-pay ───────────────────
val pendingSend by vm.pendingSend.collectAsStateWithLifecycle()
LaunchedEffect(pendingSend) {
val address = pendingSend
if (!address.isNullOrBlank() && sendState is SendState.Idle) {
input = SendInputDetector.normalize(address)
vm.scan(address, sendStrings)
vm.consumePendingSend()
}
}
// ── Collect NFC offer at top level — never inside a conditional ─────────── // ── Collect NFC offer at top level — never inside a conditional ───────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle() val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
@@ -275,56 +288,34 @@ fun SendScreen(
} else null } else null
val feeText = when { val feeText = when {
state.feeSats == null || state.feeSats == 0L -> state.feeSats == null || state.feeSats == 0L ->
strings.noFees // ← "No fees" strings.noFees
else -> { else -> {
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else "" val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
strings.routingFee(state.feeSats, ppmSuffix) // ← "Routing fee: X sats (Y ppm)" strings.routingFee(state.feeSats, ppmSuffix)
} }
} }
Column( PaymentSuccessContent(
modifier = Modifier.fillMaxSize(), title = strings.paymentSent,
horizontalAlignment = Alignment.CenterHorizontally, amountSats = state.amountSats,
verticalArrangement = Arrangement.Center fiatLabel = fiatLabel,
) { subLine = feeText,
Icon( detailsEnabled = state.pendingRecord != null,
imageVector = Icons.Filled.CheckCircle, onDetails = {
contentDescription = null, val record = state.pendingRecord
tint = MaterialTheme.colorScheme.primary, if (record != null) onViewDetails(state.paymentHash, record)
modifier = Modifier.size(72.dp) },
) secondaryLabel = strings.sendAnother,
Spacer(Modifier.height(16.dp)) onSecondary = { vm.resetSendState(); input = "" },
Text( lnurl = state.lnurl, // null for plain Bolt11
text = strings.paymentSent, // ← "Payment sent!" onSaveContact = vm::saveContactFromSend,
style = MaterialTheme.typography.headlineSmall contactName = state.contactName,
) contactId = state.contactId,
Spacer(Modifier.height(8.dp)) onViewContact = onViewContact
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel) )
Spacer(Modifier.height(4.dp))
Text(
text = feeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Button(
onClick = {
val record = state.pendingRecord
if (record != null) {
onViewDetails(state.paymentHash, record)
}
},
enabled = state.pendingRecord != null,
modifier = Modifier.fillMaxWidth()
) { Text(strings.details) } // ← "Details"
Spacer(Modifier.height(32.dp))
TextButton(
onClick = { vm.resetSendState(); input = "" },
modifier = Modifier.fillMaxWidth()
) { Text(strings.sendAnother) } // ← "Send Another"
}
} }
is SendState.Error -> { is SendState.Error -> {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.util.Bip21Parser import com.bitcointxoko.gudariwallet.util.Bip21Parser
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -35,35 +38,32 @@ sealed class SendEvent {
object PaymentCompleted : 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( data class SendStrings(
val lnurlAuthNotSupported : String, val lnurlAuthNotSupported : String,
val unrecognisedInput : String, val unrecognisedInput : String,
val paymentFailed : String, val paymentFailed : String,
val couldNotFetchInvoice : String, val couldNotFetchInvoice : String,
val couldNotDecodeInvoice : String, val couldNotDecodeInvoice : String,
val onChainNotSupported : String, val onChainNotSupported : String,
val channelRequestNotSupported : String, val channelRequestNotSupported : String,
val couldNotResolveAddress : String, val couldNotResolveAddress : String,
val emptyResponse : String, val emptyResponse : String,
val unsupportedType : (tag: String) -> String, val unsupportedType : (tag: String) -> String,
val paymentFailedRescan : String, val paymentFailedRescan : String,
val authFailed : (msg: String) -> String, val authFailed : (msg: String) -> String,
) )
class SendViewModel( class SendViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val lnurlCache : LnurlCacheRepository, private val lnurlCache : LnurlCacheRepository,
private val scanner : LnurlScanner, private val scanner : LnurlScanner,
private val payer : LnurlPayer, private val payer : LnurlPayer,
private val poller : PaymentPoller, private val poller : PaymentPoller,
private val contactRepo : ContactRepository, // ← new (Step 5)
private val paymentCacheRepo : PaymentCacheRepository, // ← new (Step 8)
) : ViewModel() { ) : ViewModel() {
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1) private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
val events: SharedFlow<SendEvent> = _events val events: SharedFlow<SendEvent> = _events
@@ -75,17 +75,18 @@ class SendViewModel(
} }
// ── Entry point ────────────────────────────────────────────────────────── // ── Entry point ──────────────────────────────────────────────────────────
fun scan(rawInput: String, strings: SendStrings) { fun scan(rawInput: String, strings: SendStrings) {
val normalized = SendInputDetector.normalize(rawInput) val normalized = SendInputDetector.normalize(rawInput)
val type = SendInputDetector.detect(rawInput) val type = SendInputDetector.detect(rawInput)
Timber.d("SCAN [DETECT] input='$rawInput' → type=$type") Timber.d("SCAN [DETECT] input='$rawInput' → type=$type")
when (type) { when (type) {
SendInputType.Unknown -> { SendInputType.Unknown -> {
val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported" strings.lnurlAuthNotSupported
else else
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" strings.unrecognisedInput
_sendState.value = SendState.Error(msg) _sendState.value = SendState.Error(msg)
} }
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings) SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
@@ -95,10 +96,11 @@ class SendViewModel(
} }
} }
// ── Payment ────────────────────────────────────────────────────────────── // ── Payment — Bolt11 ─────────────────────────────────────────────────────
fun payBolt11(bolt11: String, strings: SendStrings) { fun payBolt11(bolt11: String, strings: SendStrings) {
viewModelScope.launch { viewModelScope.launch {
val decoded = _sendState.value as? SendState.Bolt11Decoded val decoded = _sendState.value as? SendState.Bolt11Decoded
if (decoded == null) { if (decoded == null) {
Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}") Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}")
return@launch return@launch
@@ -110,138 +112,38 @@ class SendViewModel(
handlePaymentResult( handlePaymentResult(
paymentHash = response.paymentHash, paymentHash = response.paymentHash,
amountSats = decoded.amountSats, amountSats = decoded.amountSats,
bolt11 = decoded.bolt11, bolt11 = decoded.bolt11,
memo = decoded.memo, memo = decoded.memo,
pubkey = decoded.payee, pubkey = decoded.payee,
alias = decoded.nodeAlias, alias = decoded.nodeAlias,
strings strings = strings,
contactId = null, // plain bolt11 path — no contact context
) )
} }
.onFailure { e -> .onFailure { e ->
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}") Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.paymentFailed) // ← "Payment failed" parseApiError(e, strings.paymentFailed)
) )
} }
} }
} }
private suspend fun handlePaymentResult( // ── Payment — LNURL (amount form → confirmation card) ────────────────────
paymentHash : String,
amountSats : Long,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
strings : SendStrings,
) {
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
when {
// ── Settled immediately
detail?.paid == true -> {
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias)
}
// ── 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)
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)
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?,
) {
_sendState.value = SendState.PaymentSent(
amountSats = amountSats,
feeSats = feeSats,
paymentHash = paymentHash,
pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias)
)
_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,
)
} else {
_sendState.value = SendState.Error(
parseApiError(
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
strings.paymentFailed
)
)
}
}
/**
* 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( fun fetchLnurlInvoice(
rawRes : LnurlScanResponse, rawRes : LnurlScanResponse,
lnurl : String, lnurl : String,
domain : String, domain : String,
amountMsat: Long, amountMsat : Long,
comment : String?, comment : String?,
strings : SendStrings, strings : SendStrings,
contactId : String?, // ← new (Step 3)
) { ) {
viewModelScope.launch { viewModelScope.launch {
_sendState.value = SendState.Scanning _sendState.value = SendState.Scanning
@@ -251,7 +153,7 @@ class SendViewModel(
}.getOrElse { e -> }.getOrElse { e ->
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}") Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient" parseApiError(e, strings.couldNotFetchInvoice)
) )
return@launch return@launch
} }
@@ -261,7 +163,7 @@ class SendViewModel(
}.getOrElse { e -> }.getOrElse { e ->
Timber.e("LNURL [DECODE ERR] ${e.message}") Timber.e("LNURL [DECODE ERR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" parseApiError(e, strings.couldNotDecodeInvoice)
) )
return@launch return@launch
} }
@@ -271,8 +173,11 @@ class SendViewModel(
hints = decoded.routeHints hints = decoded.routeHints
) )
Timber.d("LNURL [INVOICE READY] amountMsat=$amountMsat " + Timber.d(
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}") "LNURL [INVOICE READY] amountMsat=$amountMsat " +
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size} " +
"contactId=$contactId"
)
_sendState.value = SendState.LnurlInvoiceReady( _sendState.value = SendState.LnurlInvoiceReady(
bolt11 = bolt11, bolt11 = bolt11,
@@ -290,7 +195,8 @@ class SendViewModel(
routeHintAliases = emptyMap(), routeHintAliases = emptyMap(),
paymentHash = decoded.paymentHash, paymentHash = decoded.paymentHash,
createdAt = decoded.createdAt, createdAt = decoded.createdAt,
expiresAt = decoded.expiresAt expiresAt = decoded.expiresAt,
contactId = contactId, // ← new (Step 3)
) )
resolveAliasesInBackground( resolveAliasesInBackground(
@@ -309,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) { fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
viewModelScope.launch { viewModelScope.launch {
_sendState.value = SendState.Paying _sendState.value = SendState.Paying
@@ -323,17 +233,192 @@ class SendViewModel(
pubkey = state.payee, pubkey = state.payee,
alias = state.nodeAlias, alias = state.nodeAlias,
strings = strings, strings = strings,
contactId = state.contactId, // ← new (Step 5)
) )
return@launch return@launch
} }
Timber.w("LNURL [PAY INVOICE DIRECT FAIL] " + Timber.w(
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl") "LNURL [PAY INVOICE DIRECT FAIL] " +
executeLnurlPayment(state.rawRes, state.lnurl, state.amountMsat, state.comment, strings) "${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 } 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 ──────────────────────────────────────────────────────── // ── Scan handlers ────────────────────────────────────────────────────────
private fun handleBip21Scan(rawInput: String, strings: SendStrings) { private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
@@ -343,18 +428,14 @@ class SendViewModel(
when { when {
bolt11 != null -> scan(bolt11, strings) bolt11 != null -> scan(bolt11, strings)
lnurl != null -> scan(lnurl, strings) lnurl != null -> scan(lnurl, strings)
else -> _sendState.value = SendState.Error( else -> _sendState.value = SendState.Error(strings.onChainNotSupported)
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
)
} }
} }
private fun handleLud17Scan(rawInput: String, strings: SendStrings) { private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
val scheme = rawInput.trim().lowercase().substringBefore("://") val scheme = rawInput.trim().lowercase().substringBefore("://")
if (scheme == "lnurlc") { if (scheme == "lnurlc") {
_sendState.value = SendState.Error( _sendState.value = SendState.Error(strings.channelRequestNotSupported)
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
)
return return
} }
val httpsUrl = "https://" + rawInput.trim().substringAfter("://") val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
@@ -365,20 +446,20 @@ class SendViewModel(
.onSuccess { raw -> .onSuccess { raw ->
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}") Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
when (raw.tag) { when (raw.tag) {
"withdrawRequest" -> {emitWithdrawScanned(raw, httpsUrl)} "withdrawRequest" -> emitWithdrawScanned(raw, httpsUrl)
"payRequest" -> { "payRequest" -> {
lnurlCache.put(httpsUrl, raw) lnurlCache.put(httpsUrl, raw)
_sendState.value = buildLnurlReadyState(raw, httpsUrl) _sendState.value = buildLnurlReadyState(raw, httpsUrl)
} }
else -> _sendState.value = SendState.Error( else -> _sendState.value = SendState.Error(
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag" strings.unsupportedType(raw.tag ?: "unknown")
) )
} }
} }
.onFailure { e -> .onFailure { e ->
Timber.e("LUD17 [ERROR] ${e.message}") Timber.e("LUD17 [ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address" parseApiError(e, strings.couldNotResolveAddress)
) )
} }
} }
@@ -423,7 +504,7 @@ class SendViewModel(
.onFailure { e -> .onFailure { e ->
Timber.e("SEND [DECODE ERROR] ${e.message}") Timber.e("SEND [DECODE ERROR] ${e.message}")
_sendState.value = SendState.Error( _sendState.value = SendState.Error(
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" parseApiError(e, strings.couldNotDecodeInvoice)
) )
} }
} }
@@ -447,10 +528,10 @@ class SendViewModel(
if (!fromCache) lnurlCache.put(normalized, raw) if (!fromCache) lnurlCache.put(normalized, raw)
when (raw.tag) { when (raw.tag) {
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized) "payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
"withdrawRequest" -> emitWithdrawScanned(raw, normalized) "withdrawRequest" -> emitWithdrawScanned(raw, normalized)
null -> _sendState.value = SendState.Error(strings.emptyResponse) null -> _sendState.value = SendState.Error(strings.emptyResponse)
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag)) else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
} }
} }
} }
@@ -478,19 +559,19 @@ class SendViewModel(
payee : String?, payee : String?,
routeHints : List<RouteHintHop>, routeHints : List<RouteHintHop>,
copyAlias : (SendState, String) -> SendState?, copyAlias : (SendState, String) -> SendState?,
copyHint : (SendState, String, String) -> SendState? copyHint : (SendState, String, String) -> SendState?,
) { ) {
payee?.let { pubkey -> payee?.let { pubkey ->
viewModelScope.launch { viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyAlias(it, alias) ?: it } _sendState.update { copyAlias(it, alias) ?: it }
} }
} }
routeHints.map { it.publicKey }.distinct().forEach { pubkey -> routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
viewModelScope.launch { viewModelScope.launch {
val alias = aliasRepo.resolve(pubkey) ?: return@launch val alias = aliasRepo.resolve(pubkey) ?: return@launch
_sendState.update { copyHint(it, pubkey, alias) ?: it } _sendState.update { copyHint(it, pubkey, alias) ?: it }
} }
} }
} }
} }
@@ -1,90 +1,81 @@
package com.bitcointxoko.gudariwallet.util package com.bitcointxoko.gudariwallet.util
/** import java.text.DecimalFormatSymbols
* Converts a satoshi amount to its fiat equivalent. import java.util.Locale
*
* @param sats Amount in satoshis. // ── Public API ────────────────────────────────────────────────────────────────
* @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.
*/
fun satsToFiat(sats: Long, satsPerFiat: Double): Double = fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
sats / satsPerFiat 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 { fun formatFiat(amount: Double, currency: String): String {
val symbol = currencySymbol(currency) val symbol = currencySymbol(currency)
return when { return when {
amount == 0.0 -> "${symbol}0.00" amount == 0.0 -> "${symbol}${localiseDecimal("0.00")}"
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}" amount >= 1.0 -> {
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}" val formatted = "%.2f".format(Locale.US, amount)
else -> formatSubCent(amount, symbol) 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 = fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
formatFiat(satsToFiat(sats, satsPerFiat), currency) 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 ────────────────────────────────────────────────────────── // ── Internal helpers ──────────────────────────────────────────────────────────
/** private fun formatWithSigDigits(amount: Double, symbol: String, minDecimals: Int): String {
* Handles the sub-cent case (0 < amount < 0.01). // Use Locale.US so "." is always the decimal char — safe for arithmetic & comparison
* val raw = "%.15f".format(Locale.US, amount)
* Finds the first two significant (non-zero) digits after the decimal point val dotIdx = raw.indexOf('.')
* and rounds to that precision, prefixing with "~" to signal approximation. if (dotIdx == -1) return "$symbol${localiseDecimal(raw)}"
*
* 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"
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000" val decimals = raw.substring(dotIdx + 1)
val firstSigIdx = decimals.indexOfFirst { it != '0' } val firstSigIdx = decimals.indexOfFirst { it != '0' }
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
// Keep all leading zeros + 2 significant digits if (firstSigIdx == -1) return "${symbol}${localiseDecimal("0.00")}"
val precision = firstSigIdx + 2
val rounded = "%.${precision}f".format(amount)
// Only add "~" if rounding actually changed the value val sigPrecision = firstSigIdx + 2
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount val precision = maxOf(sigPrecision, minDecimals)
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
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. * Converts a US-formatted decimal string (dot separator, no grouping)
* Falls back to the currency code + space for unlisted currencies. * 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) { private fun currencySymbol(currency: String): String = when (currency) {
"USD" -> "$" "USD" -> "$"
"EUR" -> "" "EUR" -> ""
@@ -96,12 +87,3 @@ private fun currencySymbol(currency: String): String = when (currency) {
"CHF" -> "Fr" "CHF" -> "Fr"
else -> "$currency " 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