Compare commits
5 Commits
0e7469053d
...
985c80373d
| Author | SHA1 | Date | |
|---|---|---|---|
| 985c80373d | |||
| bcea6451e7 | |||
| 53cb600408 | |||
| effe8f4b04 | |||
| c817768e8a |
@@ -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 }
|
||||||
|
.also { map ->
|
||||||
|
Timber.d(
|
||||||
|
"CONTACT [MAP EMIT ] ${map.size} entries: ${
|
||||||
|
map.entries.take(5).joinToString { "${it.key.take(8)}→${it.value}" }
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactName(query: String): List<String> =
|
suspend fun findPaymentHashesByContactName(query: String): List<String> =
|
||||||
dao.findCheckingIdsByContactName(query)
|
dao.findPaymentHashesByContactName(query)
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
|
||||||
dao.findCheckingIdsByContactIds(contactIds)
|
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 ->
|
||||||
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()
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
-18
@@ -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,58 +72,96 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when (val state = uiState) {
|
val focusRequester = remember { FocusRequester() }
|
||||||
is ContactsUiState.Loading -> {
|
val clipboard = LocalClipboard.current
|
||||||
Box(
|
val scope = rememberCoroutineScope()
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
LaunchedEffect(searchVisible) {
|
||||||
.padding(innerPadding),
|
if (searchVisible) {
|
||||||
contentAlignment = Alignment.Center
|
// kotlinx.coroutines.delay(50)
|
||||||
) {
|
focusRequester.requestFocus()
|
||||||
CircularProgressIndicator()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
if (searchVisible) {
|
||||||
|
ContactsSearchBar(
|
||||||
|
query = searchQuery,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
clipboard = clipboard,
|
||||||
|
scope = scope,
|
||||||
|
onQueryChange = { searchQuery = it },
|
||||||
|
onClear = { searchQuery = "" }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ContactsUiState.Success -> {
|
when (val state = uiState) {
|
||||||
if (state.contacts.isEmpty()) {
|
is ContactsUiState.Loading -> {
|
||||||
ContactsEmptyContent(
|
Box(
|
||||||
onAdd = { showCreateSheet = true },
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier
|
contentAlignment = Alignment.Center
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
items(state.contacts, key = { it.contact.id }) { item ->
|
CircularProgressIndicator()
|
||||||
ContactRow(item, onClick = { onContactClick(item.contact.id) })
|
}
|
||||||
HorizontalDivider(
|
}
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
thickness = 0.5.dp
|
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) {
|
if (showCreateSheet) {
|
||||||
CreateContactSheet(
|
CreateContactSheet(
|
||||||
onSave = { name, addressType, address ->
|
onSave = { name, addressType, address ->
|
||||||
viewModel.createContact(name, addressType, address)
|
viewModel.createContact(name, addressType, address)
|
||||||
showCreateSheet = false
|
showCreateSheet = false
|
||||||
},
|
},
|
||||||
onDismiss = { showCreateSheet = false }
|
onDismiss = { showCreateSheet = false }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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,37 +305,73 @@ private fun PaymentDetailContent(
|
|||||||
|
|
||||||
// ── Contact section ────────────────────────────────────────────────────
|
// ── Contact section ────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = "Contact") {
|
if (linkedContacts.isEmpty()) {
|
||||||
if (linkedContacts.isEmpty()) {
|
// No contacts — just show an assign button
|
||||||
// No contact linked — show assign button
|
OutlinedButton(
|
||||||
OutlinedButton(
|
onClick = onAssignContact,
|
||||||
onClick = onAssignContact,
|
modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxWidth()
|
Icon(
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
imageVector = Icons.Default.PersonAdd,
|
||||||
) {
|
contentDescription = null,
|
||||||
Icon(
|
modifier = Modifier.size(18.dp)
|
||||||
imageVector = Icons.Default.PersonAdd,
|
)
|
||||||
contentDescription = null,
|
Spacer(Modifier.width(6.dp))
|
||||||
modifier = Modifier.size(18.dp)
|
Text("Assign contact")
|
||||||
)
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
} else {
|
||||||
Text("Assign contact")
|
// Show contacts with avatar + name, plus edit button
|
||||||
}
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
} else {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
// Show each linked contact with a remove button
|
// Header row: title + edit button
|
||||||
linkedContacts.forEach { contact ->
|
Row(
|
||||||
val name = contact.localAlias
|
modifier = Modifier.fillMaxWidth(),
|
||||||
?: contact.displayName
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
?: contact.name
|
verticalAlignment = Alignment.CenterVertically
|
||||||
?: "Unknown"
|
) {
|
||||||
DetailRow(
|
Text(
|
||||||
label = "Contact",
|
text = "Contacts",
|
||||||
value = name,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
trailingContent = {
|
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
|
||||||
|
?: 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(
|
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,36 +381,20 @@ private fun PaymentDetailContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
if (index < linkedContacts.lastIndex) {
|
||||||
// Allow assigning additional contacts
|
HorizontalDivider(
|
||||||
HorizontalDivider(
|
thickness = 0.5.dp,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
thickness = 0.5.dp
|
)
|
||||||
)
|
}
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.TextButton(onClick = onAssignContact) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PersonAdd,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Add another",
|
|
||||||
style = MaterialTheme.typography.labelMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Basic info ─────────────────────────────────────────────────────────
|
// ── Basic info ─────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionDetails) {
|
DetailSection(title = strings.history.detailSectionDetails) {
|
||||||
|
|||||||
@@ -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,45 +144,106 @@ internal fun LnurlPayForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
// ── Headroom above amount row — the picker has a 32 dp ghost item
|
||||||
|
// above the selected item; this offsets so the visual centre of
|
||||||
|
// the picker lines up with the title text above rather than the
|
||||||
|
// Row's top edge.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// ── Amount field — unchanged ──────────────────────────────────────
|
// ── Amount field with currency switcher ───────────────────────────
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = amount,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text(strings.receive.amountInSats) },
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
isError = amount.isNotBlank() && !isAmountValid,
|
) {
|
||||||
supportingText = {
|
|
||||||
when {
|
|
||||||
amount.isNotBlank() && !isAmountValid -> {
|
|
||||||
Text(
|
|
||||||
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
fiatRate != null && fiatCurrency != null -> {
|
|
||||||
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
|
||||||
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
|
||||||
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
|
||||||
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
|
||||||
}
|
|
||||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isFixed,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Comment field — unchanged ─────────────────────────────────────
|
|
||||||
if (state.commentAllowed > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = comment,
|
value = amountText,
|
||||||
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
onValueChange = {
|
||||||
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
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)) },
|
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -154,17 +251,17 @@ 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) {
|
||||||
vm.fetchLnurlInvoice(
|
vm.fetchLnurlInvoice(
|
||||||
rawRes = state.rawRes,
|
rawRes = state.rawRes,
|
||||||
lnurl = state.lnurl,
|
lnurl = state.lnurl,
|
||||||
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
|
contactId = existingContact?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user