Compare commits

5 Commits

Author SHA1 Message Date
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
19 changed files with 916 additions and 317 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
} }
@@ -4,6 +4,7 @@ import android.app.Application
import com.bitcointxoko.gudariwallet.data.db.* import com.bitcointxoko.gudariwallet.data.db.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber
class ContactRepository(app: Application) { class ContactRepository(app: Application) {
private val dao = AppDatabase.getInstance(app).contactDao() private val dao = AppDatabase.getInstance(app).contactDao()
@@ -103,6 +104,35 @@ class ContactRepository(app: Application) {
) )
} }
suspend fun updateAddress(
addressId: String,
type: PaymentAddressType,
address: String,
label: String?,
makeDefault: Boolean
) {
val existing = dao.getAddressById(addressId) ?: return
val contactId = existing.contactId
if (makeDefault) dao.clearDefault(contactId, type.name)
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
dao.getById(contactId)?.let {
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
}
}
dao.updateAddress(
addressId = addressId,
type = type.name,
address = address,
label = label,
isDefault = makeDefault
)
}
suspend fun removeAddress(addressId: String) { suspend fun removeAddress(addressId: String) {
dao.deleteAddressById(addressId) dao.deleteAddressById(addressId)
} }
@@ -125,15 +155,17 @@ class ContactRepository(app: Application) {
// --- Contact-Tx link --- // --- Contact-Tx link ---
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> = fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(checkingId) dao.observeContactsForPayment(paymentHash)
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) { suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role)) Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
} }
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) { suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
dao.unlinkPaymentFromContact(checkingId, contactId) Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
dao.unlinkPaymentFromContact(paymentHash, contactId)
} }
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> = fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
@@ -141,11 +173,20 @@ class ContactRepository(app: Application) {
fun observeAllPaymentContactNames(): Flow<Map<String, String>> = fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames() dao.observePaymentContactNames()
.map { list -> list.associate { it.checkingId to it.contactName } } .map { list ->
list.associate { it.paymentHash to it.contactName }
suspend fun findCheckingIdsByContactName(query: String): List<String> = .also { map ->
dao.findCheckingIdsByContactName(query) Timber.d(
"CONTACT [MAP EMIT ] ${map.size} entries: ${
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> = map.entries.take(5).joinToString { "${it.key.take(8)}${it.value}" }
dao.findCheckingIdsByContactIds(contactIds) }"
)
}
}
suspend fun findPaymentHashesByContactName(query: String): List<String> =
dao.findPaymentHashesByContactName(query)
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
dao.findPaymentHashesByContactIds(contactIds)
} }
@@ -58,13 +58,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 ->
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()})") 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()
@@ -130,8 +150,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}")
} }
@@ -166,11 +202,11 @@ class PaymentCacheRepository(context: Context) {
* contact ID filter. Amount/date/status are applied in SQL; * contact ID filter. Amount/date/status are applied in SQL;
* direction and type are handled in-memory afterwards (same as queryAll). * direction and type are handled in-memory afterwards (same as queryAll).
*/ */
suspend fun queryByCheckingIds( suspend fun queryByPaymentHashes(
checkingIds : List<String>, paymentHashes : List<String>,
filter : PaymentFilter filter : PaymentFilter
): List<PaymentRecord> { ): List<PaymentRecord> {
if (checkingIds.isEmpty()) return emptyList() if (paymentHashes.isEmpty()) return emptyList()
val statusPattern: String? = if (filter.statuses.isEmpty()) null else { val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
filter.statuses.flatMap { s -> filter.statuses.flatMap { s ->
when (s) { when (s) {
@@ -180,8 +216,8 @@ class PaymentCacheRepository(context: Context) {
} }
}.joinToString(",") }.joinToString(",")
} }
return dao.getByCheckingIdsFiltered( return dao.getByPaymentHashesFiltered(
checkingIds = checkingIds, paymentHashes = paymentHashes,
minAmountMsat = filter.minAmountSat?.let { it * 1000L }, minAmountMsat = filter.minAmountSat?.let { it * 1000L },
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L }, maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
minCreatedAt = filter.minCreatedAt, minCreatedAt = filter.minCreatedAt,
@@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PaymentAddressEntity::class, PaymentAddressEntity::class,
TxContactCrossRef::class, TxContactCrossRef::class,
], ],
version = 9, version = 10,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
override fun migrate(database: SupportSQLiteDatabase) {
// 1. Create replacement table — same schema minus the payment_records FK
database.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""")
// 2. Copy existing rows
database.execSQL("""
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
SELECT checkingId, contactId, role FROM tx_contact_links
""")
// 3. Swap tables
database.execSQL("DROP TABLE tx_contact_links")
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7, MIGRATION_6_7,
MIGRATION_7_8, MIGRATION_7_8,
MIGRATION_8_9 MIGRATION_8_9,
MIGRATION_9_10
) )
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
@@ -54,8 +54,24 @@ interface ContactDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAddress(address: PaymentAddressEntity) suspend fun insertAddress(address: PaymentAddressEntity)
@Update @Query("""
suspend fun updateAddress(address: PaymentAddressEntity) 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") @Query("DELETE FROM payment_addresses WHERE id = :id")
suspend fun deleteAddressById(id: String) suspend fun deleteAddressById(id: String)
@@ -76,51 +92,51 @@ interface ContactDao {
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef) suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link // Delete a link
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId") @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
// Delete all contact links for a payment // Delete all contact links for a payment
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId") @Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
suspend fun clearContactsForPayment(checkingId: String) suspend fun clearContactsForPayment(paymentHash: String)
// Get all contacts linked to a payment // Get all contacts linked to a payment
@Query(""" @Query("""
SELECT c.* FROM contacts c SELECT c.* FROM contacts c
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
WHERE tcl.checkingId = :checkingId WHERE tcl.paymentHash = :paymentHash
""") """)
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact // Get all payments linked to a contact
@Query(""" @Query("""
SELECT checkingId FROM tx_contact_links SELECT paymentHash FROM tx_contact_links
WHERE contactId = :contactId WHERE contactId = :contactId
ORDER BY createdAt DESC ORDER BY createdAt DESC
""") """)
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// Returns all (checkingId, displayName) pairs that have a linked contact, // Returns all (paymentHash, displayName) pairs that have a linked contact,
// so HistoryViewModel can build its lookup map in a single reactive query. // so HistoryViewModel can build its lookup map in a single reactive query.
@Query(""" @Query("""
SELECT tcl.checkingId AS checkingId, SELECT tcl.paymentHash AS paymentHash,
COALESCE(c.localAlias, c.displayName, c.name) AS contactName COALESCE(c.localAlias, c.displayName, c.name) AS contactName
FROM tx_contact_links tcl FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId INNER JOIN contacts c ON c.id = tcl.contactId
""") """)
fun observePaymentContactNames(): Flow<List<PaymentContactName>> fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// For text search matching contact names — returns checkingIds of matched payments // For text search matching contact names — returns paymentHashes of matched payments
@Query(""" @Query("""
SELECT tcl.checkingId FROM tx_contact_links tcl SELECT tcl.paymentHash FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId INNER JOIN contacts c ON c.id = tcl.contactId
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%' WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""") """)
suspend fun findCheckingIdsByContactName(query: String): List<String> suspend fun findPaymentHashesByContactName(query: String): List<String>
// For filter-by-contact — returns checkingIds for specific contact IDs // For filter-by-contact — returns paymentHashs for specific contact IDs
@Query(""" @Query("""
SELECT DISTINCT checkingId FROM tx_contact_links SELECT DISTINCT paymentHash FROM tx_contact_links
WHERE contactId IN (:contactIds) WHERE contactId IN (:contactIds)
""") """)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
} }
@@ -1,6 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName( data class PaymentContactName(
val checkingId : String, val paymentHash : String,
val contactName : String val contactName : String
) )
@@ -13,6 +13,45 @@ 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>
@@ -38,7 +77,7 @@ interface PaymentDao {
@Query(""" @Query("""
SELECT * FROM payment_records SELECT * FROM payment_records
WHERE checkingId IN (:checkingIds) WHERE paymentHash IN (:paymentHashes)
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat) AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat) AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt) AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
@@ -47,8 +86,8 @@ interface PaymentDao {
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%') ',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
ORDER BY createdAt DESC, time DESC ORDER BY createdAt DESC, time DESC
""") """)
suspend fun getByCheckingIdsFiltered( suspend fun getByPaymentHashesFiltered(
checkingIds : List<String>, paymentHashes : List<String>,
minAmountMsat : Long? = null, minAmountMsat : Long? = null,
maxAmountMsat : Long? = null, maxAmountMsat : Long? = null,
minCreatedAt : Long? = null, minCreatedAt : Long? = null,
@@ -6,28 +6,10 @@ import androidx.room.Index
@Entity( @Entity(
tableName = "tx_contact_links", tableName = "tx_contact_links",
primaryKeys = ["checkingId", "contactId"], primaryKeys = ["paymentHash", "contactId"]
foreignKeys = [
ForeignKey(
entity = PaymentRecordEntity::class,
parentColumns = ["checkingId"],
childColumns = ["checkingId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("checkingId"),
Index("contactId")
]
) )
data class TxContactCrossRef( data class TxContactCrossRef(
val checkingId : String, val paymentHash : String,
val contactId : String, val contactId : String,
val role : String? = null, val role : String? = null,
val createdAt : Long = System.currentTimeMillis() val createdAt : Long = System.currentTimeMillis()
@@ -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)
)
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Icons import androidx.compose.material.icons.Icons
@@ -49,6 +48,7 @@ fun ContactDetailScreen(
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) } var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -131,8 +131,9 @@ fun ContactDetailScreen(
onSetDefault = { viewModel.setDefaultAddress(it) }, onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress, onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() }, onEditAlias = { viewModel.openEditAliasSheet() },
onEditAddress = { addr -> editingAddress = addr },
onDeleteContact = { viewModel.requestDelete() }, onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
) )
} }
} }
@@ -189,6 +190,18 @@ fun ContactDetailScreen(
) )
} }
// ── Edit address sheet ──────────────────────────────────────────
editingAddress?.let { addr ->
EditAddressSheet(
address = addr,
onSave = { id, type, addressValue, label, makeDefault ->
viewModel.updateAddress(id, type, addressValue, label, makeDefault)
editingAddress = null
},
onDismiss = { editingAddress = null }
)
}
// ── Edit alias sheet ────────────────────────────────────────────────── // ── Edit alias sheet ──────────────────────────────────────────────────
if (showEditAliasSheet) { if (showEditAliasSheet) {
val currentAlias = (uiState as? ContactDetailUiState.Success) val currentAlias = (uiState as? ContactDetailUiState.Success)
@@ -214,6 +227,7 @@ private fun ContactDetailContent(
onSetDefault: (PaymentAddressEntity) -> Unit, onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit, onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit, onEditAlias: () -> Unit,
onEditAddress: (PaymentAddressEntity) -> Unit,
onDeleteContact: () -> Unit, onDeleteContact: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -282,7 +296,8 @@ private fun ContactDetailContent(
isEditing = isEditing, isEditing = isEditing,
onPay = { onPayAddress(addr.address) }, onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) }, onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) } onSetDefault = { onSetDefault(addr) },
onEdit = { onEditAddress(addr) }
) )
if (index < addresses.lastIndex) { if (index < addresses.lastIndex) {
HorizontalDivider( HorizontalDivider(
@@ -357,6 +372,7 @@ private fun AddressRow(
onPay: () -> Unit, onPay: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onSetDefault: () -> Unit, onSetDefault: () -> Unit,
onEdit: () -> Unit
) { ) {
val typeLabel = when (address.type) { val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address" PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
@@ -380,6 +396,13 @@ private fun AddressRow(
) { ) {
// ── Label + value ───────────────────────────────────────────── // ── Label + value ─────────────────────────────────────────────
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -409,12 +432,6 @@ private fun AddressRow(
) )
} }
} }
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} }
// ── Trailing actions ────────────────────────────────────────── // ── Trailing actions ──────────────────────────────────────────
@@ -428,17 +445,14 @@ private fun AddressRow(
// Star + delete only in edit mode // Star + delete only in edit mode
if (isEditing) { if (isEditing) {
IconButton( IconButton(
onClick = onSetDefault, onClick = onEdit,
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = if (address.isDefault) Icons.Filled.Star imageVector = Icons.Default.Edit,
else Icons.Outlined.Star, contentDescription = "Edit address",
contentDescription = if (address.isDefault) "Default address"
else "Set as default",
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
IconButton( IconButton(
@@ -458,8 +472,6 @@ private fun AddressRow(
} }
// ── Add address sheet ────────────────────────────────────────────────────────── // ── Add address sheet ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -585,6 +597,136 @@ private fun AddAddressSheet(
} }
} }
// ── Edit address sheet ────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditAddressSheet(
address: PaymentAddressEntity,
onSave: (id: String, type: PaymentAddressType, addressValue: String, label: String?, makeDefault: Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val addressFocus = remember { FocusRequester() }
var selectedType by remember {
mutableStateOf(PaymentAddressType.entries.find { it.name == address.type }
?: PaymentAddressType.LIGHTNING_ADDRESS)
}
var addressValue by remember { mutableStateOf(address.address) }
var label by remember { mutableStateOf(address.label ?: "") }
var makeDefault by remember { mutableStateOf(address.isDefault) }
val canSave = addressValue.isNotBlank()
LaunchedEffect(Unit) { addressFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Edit payment address", style = MaterialTheme.typography.titleMedium)
// Type chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS },
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = { selectedType = PaymentAddressType.LNURL },
label = { Text("LNURL") }
)
}
// Address field
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"Lightning Address" else "LNURL"
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"user@domain.com" else "LNURL1..."
OutlinedTextField(
value = addressValue,
onValueChange = { addressValue = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(addressFocus)
)
// Optional label
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
placeholder = { Text("e.g. personal, savings") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
}
),
modifier = Modifier.fillMaxWidth()
)
// Set as default toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Set as default",
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = makeDefault,
onCheckedChange = { makeDefault = it }
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = {
focusManager.clearFocus()
onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
},
enabled = canSave
) { Text("Save") }
}
}
}
}
// ── Edit alias sheet ─────────────────────────────────────────────────────────── // ── Edit alias sheet ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -68,6 +68,19 @@ class ContactDetailViewModel(
} }
} }
fun updateAddress(
addressId: String,
type: PaymentAddressType,
address: String,
label: String?,
makeDefault: Boolean
) {
viewModelScope.launch {
repo.updateAddress(addressId, type, address, label, makeDefault)
}
}
fun removeAddress(addressId: String) { fun removeAddress(addressId: String) {
viewModelScope.launch { repo.removeAddress(addressId) } viewModelScope.launch { repo.removeAddress(addressId) }
} }
@@ -7,17 +7,26 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add 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.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -29,6 +38,8 @@ fun ContactsScreen(
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCreateSheet by remember { mutableStateOf(false) } var showCreateSheet by remember { mutableStateOf(false) }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -41,6 +52,17 @@ fun ContactsScreen(
contentDescription = strings.back contentDescription = strings.back
) )
} }
},
actions = {
IconButton(onClick = {
searchVisible = !searchVisible
if (!searchVisible) searchQuery = ""
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search contacts"
)
}
} }
) )
}, },
@@ -50,12 +72,38 @@ fun ContactsScreen(
} }
} }
) { innerPadding -> ) { 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) { when (val state = uiState) {
is ContactsUiState.Loading -> { is ContactsUiState.Loading -> {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -63,21 +111,32 @@ fun ContactsScreen(
} }
is ContactsUiState.Success -> { is ContactsUiState.Success -> {
if (state.contacts.isEmpty()) { 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( ContactsEmptyContent(
onAdd = { showCreateSheet = true }, onAdd = { showCreateSheet = true },
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(innerPadding)
) )
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
items(state.contacts, key = { it.contact.id }) { item -> items(filteredContacts, key = { it.contact.id }) { item ->
ContactRow(item, onClick = { onContactClick(item.contact.id) }) ContactRow(item, onClick = { onContactClick(item.contact.id) })
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
@@ -100,8 +159,9 @@ fun ContactsScreen(
) )
} }
} }
}
// ── Empty state ──────────────────────────────────────────────────────────────── // ── Empty state ──────────────────────────────────────────────────────────────
@Composable @Composable
private fun ContactsEmptyContent( private fun ContactsEmptyContent(
@@ -140,7 +200,65 @@ private fun ContactsEmptyContent(
} }
} }
// ── Contact row ──────────────────────────────────────────────────────────────── // ── Search bar ───────────────────────────────────────────────────────────────
@Composable
private fun ContactsSearchBar(
query: String,
focusRequester: FocusRequester,
clipboard: androidx.compose.ui.platform.Clipboard,
scope: CoroutineScope,
onQueryChange: (String) -> Unit,
onClear: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search contacts") },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
)
IconButton(onClick = {
scope.launch {
val pasted = clipboard.getClipEntry()
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
onQueryChange(pasted)
}
}) {
Icon(
imageVector = Icons.Default.ContentPaste,
contentDescription = "Paste"
)
}
IconButton(
onClick = onClear,
enabled = query.isNotEmpty()
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
}
}
}
// ── Contact row ──────────────────────────────────────────────────────────────
@Composable @Composable
private fun ContactRow( private fun ContactRow(
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -50,6 +51,7 @@ class ContactsViewModel(
} }
} }
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory { class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
@@ -235,7 +235,7 @@ fun HistoryScreen(
payment = payment, payment = payment,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.checkingId], contactName = contactNames[payment.paymentHash],
onClick = { onPaymentClick(payment) } onClick = { onPaymentClick(payment) }
) )
HorizontalDivider( HorizontalDivider(
@@ -133,13 +133,22 @@ class HistoryViewModel(
} }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults -> val filteredState = combine(
syncManager.state,
_filter, _roomResults,
contactRepo.observeAllPaymentContactNames()
) { s, f, roomResults, contactNames ->
Timber.d("CONTACT [COMBINE ] contactNames has ${contactNames.size} entries")
if (s !is HistoryState.Success) return@combine s if (s !is HistoryState.Success) return@combine s
if (roomResults == null) { val payments = if (roomResults == null) {
s.copy(payments = s.payments.applyInMemoryFilters(f)) s.payments.applyInMemoryFilters(f)
} else { } else {
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false) roomResults.applyInMemoryFilters(f)
} }
s.copy(
payments = payments,
canLoadMore = roomResults == null && s.canLoadMore
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
// ── In-memory filter logic ──────────────────────────────────────────────── // ── In-memory filter logic ────────────────────────────────────────────────
@@ -164,7 +173,7 @@ class HistoryViewModel(
contactRepo.observeAllPaymentContactNames() contactRepo.observeAllPaymentContactNames()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.Eagerly,
initialValue = emptyMap() initialValue = emptyMap()
) )
// Maps contactId → display name (for filter chips) // Maps contactId → display name (for filter chips)
@@ -203,6 +212,7 @@ class HistoryViewModel(
) )
fun assignContact(paymentHash: String, contactId: String) { fun assignContact(paymentHash: String, contactId: String) {
Timber.d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
viewModelScope.launch { viewModelScope.launch {
contactRepo.linkPaymentToContact(paymentHash, contactId) contactRepo.linkPaymentToContact(paymentHash, contactId)
} }
@@ -220,6 +230,7 @@ class HistoryViewModel(
addressType : PaymentAddressType?, addressType : PaymentAddressType?,
address : String? address : String?
) { ) {
Timber.d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
viewModelScope.launch { viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS } val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL } val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
@@ -228,6 +239,7 @@ class HistoryViewModel(
lnAddress = lnAddress, lnAddress = lnAddress,
lnUrl = lnUrl lnUrl = lnUrl
) )
Timber.d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking to paymentHash=$paymentHash")
contactRepo.linkPaymentToContact(paymentHash, contact.id) contactRepo.linkPaymentToContact(paymentHash, contact.id)
} }
} }
@@ -265,11 +277,11 @@ class HistoryViewModel(
// ── Supplemental contact search ─────────────────────── // ── Supplemental contact search ───────────────────────
// 1. Text query may match contact names not in payment_records // 1. Text query may match contact names not in payment_records
if (f.searchQuery.isNotBlank()) { if (f.searchQuery.isNotBlank()) {
val contactCheckingIds = contactRepo val contactPaymentHashes = contactRepo
.findCheckingIdsByContactName(f.searchQuery) .findPaymentHashesByContactName(f.searchQuery)
if (contactCheckingIds.isNotEmpty()) { if (contactPaymentHashes.isNotEmpty()) {
val contactPayments = paymentCache val contactPayments = paymentCache
.queryByCheckingIds(contactCheckingIds, f) .queryByPaymentHashes(contactPaymentHashes, f)
// Merge without duplicates // Merge without duplicates
val existingIds = baseResults.map { it.checkingId }.toSet() val existingIds = baseResults.map { it.checkingId }.toSet()
baseResults += contactPayments baseResults += contactPayments
@@ -279,12 +291,12 @@ class HistoryViewModel(
// 2. Explicit contact ID filter // 2. Explicit contact ID filter
if (f.contactIds.isNotEmpty()) { if (f.contactIds.isNotEmpty()) {
val contactCheckingIds = contactRepo val contactPaymentHashes = contactRepo
.findCheckingIdsByContactIds(f.contactIds.toList()) .findPaymentHashesByContactIds(f.contactIds.toList())
if (contactCheckingIds.isNotEmpty()) { if (contactPaymentHashes.isNotEmpty()) {
// Fetch ALL payments for these contacts, WITH the full filter applied // Fetch ALL payments for these contacts, WITH the full filter applied
val contactFiltered = paymentCache.queryByCheckingIds( val contactFiltered = paymentCache.queryByPaymentHashes(
checkingIds = contactCheckingIds, paymentHashes = contactPaymentHashes,
filter = f filter = f
) )
_roomResults.value = contactFiltered _roomResults.value = contactFiltered
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.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.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd 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
@@ -55,6 +56,7 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
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
@@ -303,14 +305,11 @@ private fun PaymentDetailContent(
// ── Contact section ──────────────────────────────────────────────────── // ── Contact section ────────────────────────────────────────────────────
item { item {
DetailSection(title = "Contact") {
if (linkedContacts.isEmpty()) { if (linkedContacts.isEmpty()) {
// No contact linked — show assign button // No contacts — just show an assign button
OutlinedButton( OutlinedButton(
onClick = onAssignContact, onClick = onAssignContact,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.PersonAdd, imageVector = Icons.Default.PersonAdd,
@@ -321,19 +320,58 @@ private fun PaymentDetailContent(
Text("Assign contact") Text("Assign contact")
} }
} else { } else {
// Show each linked contact with a remove button // Show contacts with avatar + name, plus edit button
linkedContacts.forEach { contact -> Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
// Header row: title + edit button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Contacts",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(
onClick = onAssignContact,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit contacts",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(8.dp))
// Each linked contact: avatar + name + remove
linkedContacts.forEachIndexed { index, contact ->
val name = contact.localAlias val name = contact.localAlias
?: contact.displayName ?: contact.displayName
?: contact.name ?: contact.name
?: "Unknown" ?: "Unknown"
DetailRow(
label = "Contact", Row(
value = name, modifier = Modifier
trailingContent = { .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( IconButton(
onClick = { onUnassignContact(contact.id) }, onClick = { onUnassignContact(contact.id) },
modifier = Modifier.size(32.dp) modifier = Modifier.size(28.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
@@ -343,35 +381,19 @@ private fun PaymentDetailContent(
) )
} }
} }
)
} if (index < linkedContacts.lastIndex) {
// Allow assigning additional contacts
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp,
thickness = 0.5.dp color = MaterialTheme.colorScheme.outlineVariant
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.End
) {
androidx.compose.material3.TextButton(onClick = onAssignContact) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Add another",
style = MaterialTheme.typography.labelMedium
) )
} }
} }
} }
} }
} }
}
// ── Basic info ───────────────────────────────────────────────────────── // ── Basic info ─────────────────────────────────────────────────────────
item { item {
@@ -1,15 +1,20 @@
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.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.Icons
import androidx.compose.material.icons.filled.PersonAdd 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.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.SuggestionChip
@@ -20,13 +25,18 @@ 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 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
@@ -37,6 +47,8 @@ import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
private enum class AmountUnit { SATS, FIAT }
@Composable @Composable
internal fun LnurlPayForm( internal fun LnurlPayForm(
state : SendState.LnurlReady, state : SendState.LnurlReady,
@@ -47,13 +59,34 @@ internal fun LnurlPayForm(
) { ) {
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
val sats: Long? = amount.toLongOrNull() val showFiatToggle = fiatRate != null && fiatCurrency != null
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
// ── Always compute sats from whatever the user typed ─────────────────
val parsedInput = amountText.trim().toDoubleOrNull()
val sats: Long? = when {
parsedInput == null -> null
activeUnit == AmountUnit.SATS -> parsedInput.toLong()
else /* FIAT */ -> (parsedInput * (fiatRate ?: 1.0)).toLong()
}
val isAmountValid = sats != null && sats in state.minSats..state.maxSats val 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 ────────── // ── Contact lookup — reactive, sourced from WalletViewModel ──────────
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle() val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
@@ -67,10 +100,14 @@ internal fun LnurlPayForm(
} }
} }
// ── The supporting-text height the picker must not center against ─────
// OutlinedTextFieldDefaults counts: input area ~56 dp, supporting
// text ~16 dp with 4 dp internal padding ≈ 20 dp extra below.
val supportingTextHeight = 20.dp
Column { Column {
// ── Recipient header ────────────────────────────────────────────── // ── Recipient header ──────────────────────────────────────────────
if (existingContact != null) { if (existingContact != null) {
// Known contact — show their name instead of the raw address
val contactName = existingContact!!.localAlias val contactName = existingContact!!.localAlias
?: existingContact!!.displayName ?: existingContact!!.displayName
?: existingContact!!.name ?: existingContact!!.name
@@ -80,7 +117,6 @@ internal fun LnurlPayForm(
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
} else { } else {
// Unknown — show domain/address as before
Text( Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
@@ -108,22 +144,51 @@ internal fun LnurlPayForm(
) )
} }
Spacer(Modifier.height(12.dp)) // ── Headroom above amount row — the picker has a 32 dp ghost item
// above the selected item; this offsets so the visual centre of
// the picker lines up with the title text above rather than the
// Row's top edge.
Spacer(Modifier.height(16.dp))
// ── Amount field — unchanged ────────────────────────────────────── // ── Amount field with currency switcher ───────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField( OutlinedTextField(
value = amount, value = amountText,
onValueChange = { if (it.all(Char::isDigit)) amount = it }, onValueChange = {
label = { Text(strings.receive.amountInSats) }, val allowed = if (activeUnit == AmountUnit.SATS)
isError = amount.isNotBlank() && !isAmountValid, 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 = { supportingText = {
when { when {
amount.isNotBlank() && !isAmountValid -> { amountText.isNotBlank() && !isAmountValid -> {
Text( Text(
text = strings.lnurlAmountError(state.minSats, state.maxSats), text = strings.lnurlAmountError(state.minSats, state.maxSats),
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
conversionLabel != null -> {
Text(
text = conversionLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
fiatRate != null && fiatCurrency != null -> { fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency) val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency) val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
@@ -136,13 +201,45 @@ internal fun LnurlPayForm(
}, },
enabled = !isFixed, enabled = !isFixed,
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), textStyle = LocalTextStyle.current.copy(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) 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)
) )
// ── Comment field — unchanged ───────────────────────────────────── // ── 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) { if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = comment, value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it }, onValueChange = { if (it.length <= state.commentAllowed) comment = it },
@@ -154,7 +251,7 @@ internal fun LnurlPayForm(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// ── Pay button — unchanged ──────────────────────────────────────── // ── Pay button ────────────────────────────────────────────────────
Button( Button(
onClick = { onClick = {
if (sats != null && isAmountValid) { if (sats != null && isAmountValid) {
@@ -173,8 +270,8 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
else strings.lnurlPayButton(amount) else strings.lnurlPayButton(satsLabelForButton)
) )
} }
} }
@@ -192,4 +289,3 @@ internal fun LnurlPayForm(
) )
} }
} }
@@ -400,7 +400,7 @@ class SendViewModel(
// ── 3. Link payment → contact (FK now guaranteed to exist) ─────── // ── 3. Link payment → contact (FK now guaranteed to exist) ───────
runCatching { runCatching {
contactRepo.linkPaymentToContact( contactRepo.linkPaymentToContact(
checkingId = paymentHash, paymentHash = paymentHash,
contactId = contactId, contactId = contactId,
role = "recipient", role = "recipient",
) )
@@ -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
* and rounds to that precision, prefixing with "~" to signal approximation.
*
* Examples:
* 0.003887 → "~$0.0039"
* 0.000038 → "~$0.000038"
* 0.0000431 → "~$0.000043"
*/
private fun formatSubCent(amount: Double, symbol: String): String {
// Render with enough decimal places to capture at least 2 significant digits
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
val dotIdx = raw.indexOf('.') val dotIdx = raw.indexOf('.')
if (dotIdx == -1) return "~$symbol$raw" if (dotIdx == -1) return "$symbol${localiseDecimal(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