Compare commits
4 Commits
4546844ea9
...
2bec9f3908
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bec9f3908 | |||
| f9991622b3 | |||
| f931405c90 | |||
| c3490808c0 |
@@ -0,0 +1,149 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class ContactRepository(app: Application) {
|
||||||
|
private val dao = AppDatabase.getInstance(app).contactDao()
|
||||||
|
|
||||||
|
// --- Observe ---
|
||||||
|
|
||||||
|
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
|
||||||
|
dao.observeAllWithAddresses()
|
||||||
|
|
||||||
|
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
||||||
|
dao.observeById(id)
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
suspend fun createContact(
|
||||||
|
localAlias: String,
|
||||||
|
lnAddress: String? = null,
|
||||||
|
lnUrl: String? = null,
|
||||||
|
pictureUrl: String? = null
|
||||||
|
): ContactEntity {
|
||||||
|
val contact = ContactEntity(
|
||||||
|
localAlias = localAlias,
|
||||||
|
displayName = null,
|
||||||
|
name = null,
|
||||||
|
pictureUrl = pictureUrl,
|
||||||
|
nostrPubkey = null,
|
||||||
|
nip05 = null,
|
||||||
|
about = null,
|
||||||
|
lnAddress = lnAddress
|
||||||
|
)
|
||||||
|
dao.insert(contact)
|
||||||
|
|
||||||
|
// Also add to payment_addresses table if lnAddress provided
|
||||||
|
lnAddress?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lnUrl?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LNURL.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = lnAddress == null // default only if no lnAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
suspend fun updateAlias(id: String, newAlias: String) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePicture(id: String, pictureUrl: String?) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
suspend fun addAddress(
|
||||||
|
contactId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String? = null,
|
||||||
|
makeDefault: Boolean = false
|
||||||
|
) {
|
||||||
|
if (makeDefault) dao.clearDefault(contactId, type.name)
|
||||||
|
|
||||||
|
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
|
||||||
|
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
|
||||||
|
dao.getById(contactId)?.let {
|
||||||
|
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type.name,
|
||||||
|
address = address,
|
||||||
|
label = label,
|
||||||
|
isDefault = makeDefault
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeAddress(addressId: String) {
|
||||||
|
dao.deleteAddressById(addressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
suspend fun deleteContact(id: String) {
|
||||||
|
dao.deleteById(id) // cascades to payment_addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lookup (for send flow) ---
|
||||||
|
|
||||||
|
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
|
||||||
|
dao.findByLnAddress(lnAddress)
|
||||||
|
|
||||||
|
suspend fun findByLnurl(lnurl: String): ContactEntity? =
|
||||||
|
dao.findByAddress(lnurl)
|
||||||
|
|
||||||
|
// --- Contact-Tx link ---
|
||||||
|
|
||||||
|
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
|
||||||
|
dao.observeContactsForPayment(checkingId)
|
||||||
|
|
||||||
|
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
|
||||||
|
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
|
||||||
|
dao.unlinkPaymentFromContact(checkingId, contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
||||||
|
dao.observePaymentIdsForContact(contactId)
|
||||||
|
|
||||||
|
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
||||||
|
dao.observePaymentContactNames()
|
||||||
|
.map { list -> list.associate { it.checkingId to it.contactName } }
|
||||||
|
|
||||||
|
suspend fun findCheckingIdsByContactName(query: String): List<String> =
|
||||||
|
dao.findCheckingIdsByContactName(query)
|
||||||
|
|
||||||
|
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
|
||||||
|
dao.findCheckingIdsByContactIds(contactIds)
|
||||||
|
}
|
||||||
@@ -159,4 +159,10 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
dao.deleteAll()
|
dao.deleteAll()
|
||||||
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetches specific payments by checkingId (used after a contact lookup)
|
||||||
|
suspend fun queryByCheckingIds(checkingIds: List<String>): List<PaymentRecord> {
|
||||||
|
if (checkingIds.isEmpty()) return emptyList()
|
||||||
|
return dao.getByCheckingIds(checkingIds).map { it.toDomain() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
LnurlCacheEntity::class,
|
LnurlCacheEntity::class,
|
||||||
NodeAliasCacheEntity::class,
|
NodeAliasCacheEntity::class,
|
||||||
NwcKeyEntity::class,
|
NwcKeyEntity::class,
|
||||||
NwcBudgetEntity::class
|
NwcBudgetEntity::class,
|
||||||
|
ContactEntity::class,
|
||||||
|
PaymentAddressEntity::class,
|
||||||
|
TxContactCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 7,
|
version = 9,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(PaymentConverters::class)
|
@TypeConverters(PaymentConverters::class)
|
||||||
@@ -26,6 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun lnurlCacheDao(): LnurlCacheDao
|
abstract fun lnurlCacheDao(): LnurlCacheDao
|
||||||
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
||||||
abstract fun nwcKeyDao(): NwcKeyDao
|
abstract fun nwcKeyDao(): NwcKeyDao
|
||||||
|
abstract fun contactDao(): ContactDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: AppDatabase? = null
|
@Volatile private var INSTANCE: AppDatabase? = null
|
||||||
@@ -124,6 +128,73 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
localAlias TEXT,
|
||||||
|
displayName TEXT,
|
||||||
|
name TEXT,
|
||||||
|
pictureUrl TEXT,
|
||||||
|
nostrPubkey TEXT,
|
||||||
|
nip05 TEXT,
|
||||||
|
about TEXT,
|
||||||
|
lnAddress TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
|
||||||
|
ON contacts(nostrPubkey)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
|
||||||
|
ON contacts(lnAddress)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_addresses (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
isDefault INTEGER NOT NULL DEFAULT 0,
|
||||||
|
addedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
|
||||||
|
ON payment_addresses(contactId)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase =
|
fun getInstance(context: Context): AppDatabase =
|
||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -137,7 +208,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
MIGRATION_3_4,
|
MIGRATION_3_4,
|
||||||
MIGRATION_4_5,
|
MIGRATION_4_5,
|
||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
MIGRATION_6_7
|
MIGRATION_6_7,
|
||||||
|
MIGRATION_7_8,
|
||||||
|
MIGRATION_8_9
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
data class ContactWithAddresses(
|
||||||
|
@Embedded val contact: ContactEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "id",
|
||||||
|
entityColumn = "contactId"
|
||||||
|
)
|
||||||
|
val addresses: List<PaymentAddressEntity>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ContactDao {
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAll(): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id")
|
||||||
|
fun observeById(id: String): Flow<ContactWithAddresses?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun getById(id: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
|
||||||
|
suspend fun findByAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
|
||||||
|
suspend fun findByLnAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
|
||||||
|
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insert(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM contacts WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM payment_addresses WHERE id = :id")
|
||||||
|
suspend fun deleteAddressById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
|
||||||
|
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
|
||||||
|
|
||||||
|
// Clear the isDefault flag for a type before setting a new default
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_addresses
|
||||||
|
SET isDefault = 0
|
||||||
|
WHERE contactId = :contactId AND type = :type
|
||||||
|
""")
|
||||||
|
suspend fun clearDefault(contactId: String, type: String)
|
||||||
|
|
||||||
|
// Insert a link
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
||||||
|
|
||||||
|
// Delete a link
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
|
||||||
|
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
|
||||||
|
|
||||||
|
// Delete all contact links for a payment
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
|
||||||
|
suspend fun clearContactsForPayment(checkingId: String)
|
||||||
|
|
||||||
|
// Get all contacts linked to a payment
|
||||||
|
@Query("""
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
||||||
|
WHERE tcl.checkingId = :checkingId
|
||||||
|
""")
|
||||||
|
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
// Get all payments linked to a contact
|
||||||
|
@Query("""
|
||||||
|
SELECT checkingId FROM tx_contact_links
|
||||||
|
WHERE contactId = :contactId
|
||||||
|
ORDER BY createdAt DESC
|
||||||
|
""")
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
||||||
|
|
||||||
|
// Returns all (checkingId, displayName) pairs that have a linked contact,
|
||||||
|
// so HistoryViewModel can build its lookup map in a single reactive query.
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.checkingId AS checkingId,
|
||||||
|
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
||||||
|
FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
""")
|
||||||
|
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
||||||
|
|
||||||
|
// For text search matching contact names — returns checkingIds of matched payments
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.checkingId FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
||||||
|
""")
|
||||||
|
suspend fun findCheckingIdsByContactName(query: String): List<String>
|
||||||
|
|
||||||
|
// For filter-by-contact — returns checkingIds for specific contact IDs
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT checkingId FROM tx_contact_links
|
||||||
|
WHERE contactId IN (:contactIds)
|
||||||
|
""")
|
||||||
|
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "contacts",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
|
||||||
|
Index(value = ["lnAddress"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ContactEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
// --- Display identity ---
|
||||||
|
val localAlias: String?, // user-set name — highest display priority
|
||||||
|
val displayName: String?, // future: from nostr kind 0 display_name
|
||||||
|
val name: String?, // future: from nostr kind 0 name
|
||||||
|
val pictureUrl: String?, // future: from nostr kind 0 picture
|
||||||
|
|
||||||
|
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
|
||||||
|
val nostrPubkey: String?, // 32-byte hex; unique index
|
||||||
|
val nip05: String?, // e.g. user@domain.com
|
||||||
|
val about: String?, // bio / notes
|
||||||
|
|
||||||
|
// --- Payment fast-access (denormalized for single-row lookup) ---
|
||||||
|
val lnAddress: String?, // primary Lightning Address (lud16)
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "payment_addresses",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ContactEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["contactId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [Index("contactId")]
|
||||||
|
)
|
||||||
|
data class PaymentAddressEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
val contactId: String, // FK → contacts.id
|
||||||
|
|
||||||
|
val type: String, // PaymentAddressType.name (stored as String)
|
||||||
|
val address: String, // the raw address value
|
||||||
|
val label: String? = null, // e.g. "personal", "savings"
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val addedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep as a simple enum in the same file or a separate file
|
||||||
|
enum class PaymentAddressType {
|
||||||
|
LIGHTNING_ADDRESS, // user@domain.com (lud16)
|
||||||
|
LNURL, // bech32 lnurl (lud06)
|
||||||
|
ON_CHAIN, // Bitcoin address
|
||||||
|
SILENT_PAYMENT, // BIP-352 sp1...
|
||||||
|
BOLT12_OFFER, // lno...
|
||||||
|
BIP353 // ₿user@domain.com
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
data class PaymentContactName(
|
||||||
|
val checkingId : String,
|
||||||
|
val contactName : String
|
||||||
|
)
|
||||||
@@ -32,6 +32,9 @@ interface PaymentDao {
|
|||||||
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
||||||
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
|
||||||
|
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM payment_records")
|
@Query("DELETE FROM payment_records")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "tx_contact_links",
|
||||||
|
primaryKeys = ["checkingId", "contactId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = PaymentRecordEntity::class,
|
||||||
|
parentColumns = ["checkingId"],
|
||||||
|
childColumns = ["checkingId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = ContactEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["contactId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("checkingId"),
|
||||||
|
Index("contactId")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class TxContactCrossRef(
|
||||||
|
val checkingId : String,
|
||||||
|
val contactId : String,
|
||||||
|
val role : String? = null,
|
||||||
|
val createdAt : Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -469,6 +469,19 @@ val EnHomeStrings = AppStrings(
|
|||||||
failedToCreateConnection = "Failed to create connection",
|
failedToCreateConnection = "Failed to create connection",
|
||||||
failedToDeleteConnection = "Failed to delete connection",
|
failedToDeleteConnection = "Failed to delete connection",
|
||||||
failedToLoadConnection = "Failed to load connection",
|
failedToLoadConnection = "Failed to load connection",
|
||||||
)
|
),
|
||||||
|
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "App Lock",
|
||||||
|
appLockDescription = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
|
||||||
|
appLockToggleLabel = "Lock app on open",
|
||||||
|
appLockToggleEnabled = "Enabled (recommended)",
|
||||||
|
appLockToggleDisabled = "Disabled",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Authenticate to continue",
|
||||||
|
unlockButton = "Unlock",
|
||||||
|
biometricPromptTitle = "Unlock Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Authenticate to access your wallet",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -470,5 +470,17 @@ val EsHomeStrings = AppStrings(
|
|||||||
failedToCreateConnection = "Error al crear la conexión",
|
failedToCreateConnection = "Error al crear la conexión",
|
||||||
failedToDeleteConnection = "Error al eliminar la conexión",
|
failedToDeleteConnection = "Error al eliminar la conexión",
|
||||||
failedToLoadConnection = "Error al cargar la conexión",
|
failedToLoadConnection = "Error al cargar la conexión",
|
||||||
)
|
),
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "Bloqueo de App",
|
||||||
|
appLockDescription = "Solicitar autenticación biométrica (huella, cara o PIN) cada vez que se abra Gudari Wallet.",
|
||||||
|
appLockToggleLabel = "Bloquear al abrir",
|
||||||
|
appLockToggleEnabled = "Activado (recomendado)",
|
||||||
|
appLockToggleDisabled = "Desactivado",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Autentícate para continuar",
|
||||||
|
unlockButton = "Desbloquear",
|
||||||
|
biometricPromptTitle = "Desbloquear Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Autentícate para acceder a tu cartera",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -470,5 +470,17 @@ val EuStrings = AppStrings(
|
|||||||
failedToCreateConnection = "Huts egin du konexioa sortzean",
|
failedToCreateConnection = "Huts egin du konexioa sortzean",
|
||||||
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
|
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
|
||||||
failedToLoadConnection = "Huts egin du konexioa kargatzean",
|
failedToLoadConnection = "Huts egin du konexioa kargatzean",
|
||||||
)
|
),
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "App Blokeoa",
|
||||||
|
appLockDescription = "Autentifikazio biometrikoa (hatz-marka, aurpegia edo PINa) eskatu Gudari Wallet irekitzen den bakoitzean.",
|
||||||
|
appLockToggleLabel = "Aplikazioa irekitzean blokeatu",
|
||||||
|
appLockToggleEnabled = "Gaituta (gomendatua)",
|
||||||
|
appLockToggleDisabled = "Desgaituta",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Autentifikatu jarraitzeko",
|
||||||
|
unlockButton = "Desblokeatu",
|
||||||
|
biometricPromptTitle = "Desblokeatu Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Autentifikatu zure zorrora sartzeko",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,7 +154,8 @@ data class AppStrings(
|
|||||||
val onboarding: OnboardingStrings,
|
val onboarding: OnboardingStrings,
|
||||||
val receive: ReceiveStrings,
|
val receive: ReceiveStrings,
|
||||||
val history: HistoryStrings,
|
val history: HistoryStrings,
|
||||||
val nwc: NwcStrings
|
val nwc: NwcStrings,
|
||||||
|
val lock: LockStrings
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OnboardingStrings(
|
data class OnboardingStrings(
|
||||||
@@ -479,4 +480,17 @@ data class NwcStrings(
|
|||||||
val failedToCreateConnection : String,
|
val failedToCreateConnection : String,
|
||||||
val failedToDeleteConnection : String,
|
val failedToDeleteConnection : String,
|
||||||
val failedToLoadConnection : String,
|
val failedToLoadConnection : String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class LockStrings(
|
||||||
|
val appLockTitle: String,
|
||||||
|
val appLockDescription: String,
|
||||||
|
val appLockToggleLabel: String,
|
||||||
|
val appLockToggleEnabled: String,
|
||||||
|
val appLockToggleDisabled: String,
|
||||||
|
val appName: String,
|
||||||
|
val authenticateToContinue: String,
|
||||||
|
val unlockButton: String,
|
||||||
|
val biometricPromptTitle: String,
|
||||||
|
val biometricPromptSubtitle: String,
|
||||||
|
)
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.filled.Cable
|
import androidx.compose.material.icons.filled.Cable
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
@@ -40,6 +41,7 @@ fun HomeTab(
|
|||||||
vm: WalletViewModel,
|
vm: WalletViewModel,
|
||||||
onHistoryClick: () -> Unit,
|
onHistoryClick: () -> Unit,
|
||||||
onNwcClick : () -> Unit,
|
onNwcClick : () -> Unit,
|
||||||
|
onContactsClick: () -> Unit,
|
||||||
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -284,19 +286,25 @@ fun HomeTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── NWC button — top-right corner ─────────────────────────────────
|
Row(
|
||||||
IconButton(
|
|
||||||
onClick = onNwcClick,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(onClick = onContactsClick) {
|
||||||
imageVector = Icons.Filled.Cable,
|
Icon(
|
||||||
contentDescription = "Wallet Connect",
|
imageVector = Icons.Filled.People,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
contentDescription = "Contacts",
|
||||||
.copy(alpha = 0.7f)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNwcClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Cable,
|
||||||
|
contentDescription = "Wallet Connect",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import cafe.adriel.lyricist.Lyricist
|
|||||||
import com.bitcointxoko.gudariwallet.MainActivity
|
import com.bitcointxoko.gudariwallet.MainActivity
|
||||||
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||||
@@ -70,6 +72,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@@ -88,6 +93,9 @@ private const val ROUTE_HISTORY = "history"
|
|||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||||
private const val ROUTE_NWC = "nwc"
|
private const val ROUTE_NWC = "nwc"
|
||||||
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||||
|
private const val ROUTE_CONTACTS = "contacts"
|
||||||
|
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
@@ -122,6 +130,7 @@ fun WalletScreen(
|
|||||||
repo = vm.repo,
|
repo = vm.repo,
|
||||||
aliasRepo = vm.aliasRepo,
|
aliasRepo = vm.aliasRepo,
|
||||||
paymentCache = vm.paymentCache,
|
paymentCache = vm.paymentCache,
|
||||||
|
contactRepo = vm.contactRepo,
|
||||||
fiatCurrency = vm.selectedCurrency,
|
fiatCurrency = vm.selectedCurrency,
|
||||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||||
)
|
)
|
||||||
@@ -130,6 +139,9 @@ fun WalletScreen(
|
|||||||
val nwcVm: NwcViewModel = viewModel(
|
val nwcVm: NwcViewModel = viewModel(
|
||||||
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
|
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
|
||||||
)
|
)
|
||||||
|
val contactsVm: ContactsViewModel = viewModel(
|
||||||
|
factory = ContactsViewModel.Factory(vm.contactRepo)
|
||||||
|
)
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
@@ -139,12 +151,16 @@ fun WalletScreen(
|
|||||||
&& currentRoute != ROUTE_HISTORY
|
&& currentRoute != ROUTE_HISTORY
|
||||||
&& currentRoute != ROUTE_NWC
|
&& currentRoute != ROUTE_NWC
|
||||||
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
||||||
|
&& currentRoute != ROUTE_CONTACTS
|
||||||
|
&& currentRoute != ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|| currentRoute == ROUTE_SCANNER
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|| currentRoute == ROUTE_NWC
|
|| currentRoute == ROUTE_NWC
|
||||||
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
||||||
|
|| currentRoute == ROUTE_CONTACTS
|
||||||
|
|| currentRoute == ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
// ── Notification tap ──────────────────────────────────────────────────────
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
val hash = pendingPaymentHash.value
|
val hash = pendingPaymentHash.value
|
||||||
@@ -341,6 +357,9 @@ fun WalletScreen(
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onContactsClick = {
|
||||||
|
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
|
||||||
|
},
|
||||||
lyricist = lyricist
|
lyricist = lyricist
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -498,6 +517,39 @@ fun WalletScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
ROUTE_CONTACTS,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) {
|
||||||
|
ContactsScreen(
|
||||||
|
viewModel = contactsVm,
|
||||||
|
onContactClick = { contactId ->
|
||||||
|
navController.navigate("contacts/$contactId") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = ROUTE_CONTACT_DETAIL,
|
||||||
|
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) { backStackEntry ->
|
||||||
|
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
||||||
|
val detailVm: ContactDetailViewModel = viewModel(
|
||||||
|
key = contactId,
|
||||||
|
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
||||||
|
)
|
||||||
|
ContactDetailScreen(
|
||||||
|
viewModel = detailVm,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clipboard banner — floats over content, slides in from top ────
|
// ── Clipboard banner — floats over content, slides in from top ────
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||||
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
@@ -21,6 +24,7 @@ import com.bitcointxoko.gudariwallet.ui.send.SendEvent
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
private const val TAG = "WalletViewModel"
|
private const val TAG = "WalletViewModel"
|
||||||
@@ -30,6 +34,7 @@ class WalletViewModel(
|
|||||||
val aliasRepo : NodeAliasRepository,
|
val aliasRepo : NodeAliasRepository,
|
||||||
val paymentCache: PaymentCacheRepository,
|
val paymentCache: PaymentCacheRepository,
|
||||||
val nwcCache : NwcCacheRepository,
|
val nwcCache : NwcCacheRepository,
|
||||||
|
val contactRepo : ContactRepository,
|
||||||
private val balancePrefs: BalancePrefsStore,
|
private val balancePrefs: BalancePrefsStore,
|
||||||
private val lnAddressStore : LightningAddressStore,
|
private val lnAddressStore : LightningAddressStore,
|
||||||
val balanceVm : BalanceViewModel,
|
val balanceVm : BalanceViewModel,
|
||||||
@@ -126,6 +131,32 @@ class WalletViewModel(
|
|||||||
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
||||||
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
||||||
|
|
||||||
|
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
|
||||||
|
sendVm.sendState
|
||||||
|
.map { state ->
|
||||||
|
val lnurl = (state as? SendState.LnurlReady)?.lnurl
|
||||||
|
?: return@map null
|
||||||
|
contactRepo.findByLnAddress(lnurl)
|
||||||
|
?: contactRepo.findByLnurl(lnurl)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observePaymentEvents()
|
observePaymentEvents()
|
||||||
fetchLightningAddress()
|
fetchLightningAddress()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
|||||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||||
@@ -37,6 +38,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
val poller = PaymentPoller(repo)
|
val poller = PaymentPoller(repo)
|
||||||
val paymentCache = PaymentCacheRepository(app)
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
val nwcCache = NwcCacheRepository(app)
|
val nwcCache = NwcCacheRepository(app)
|
||||||
|
val contactRepo = ContactRepository(app)
|
||||||
val fiatDataStore = FiatDataStore(app)
|
val fiatDataStore = FiatDataStore(app)
|
||||||
val balancePrefs = BalancePrefsStore(app)
|
val balancePrefs = BalancePrefsStore(app)
|
||||||
val lnAddressStore = LightningAddressStore(app)
|
val lnAddressStore = LightningAddressStore(app)
|
||||||
@@ -65,6 +67,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
nwcCache = nwcCache,
|
nwcCache = nwcCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
balancePrefs = balancePrefs,
|
balancePrefs = balancePrefs,
|
||||||
balanceVm = balanceVm,
|
balanceVm = balanceVm,
|
||||||
fiatVm = fiatVm,
|
fiatVm = fiatVm,
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Star
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactDetailScreen(
|
||||||
|
viewModel: ContactDetailViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
|
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
||||||
|
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
val title = when (val s = uiState) {
|
||||||
|
is ContactDetailUiState.Success ->
|
||||||
|
s.data.contact.localAlias
|
||||||
|
?: s.data.contact.displayName
|
||||||
|
?: s.data.contact.name
|
||||||
|
?: "Contact"
|
||||||
|
else -> "Contact"
|
||||||
|
}
|
||||||
|
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState is ContactDetailUiState.Success) {
|
||||||
|
IconButton(onClick = { viewModel.openEditAliasSheet() }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit name")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { viewModel.requestDelete() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Delete contact",
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (uiState is ContactDetailUiState.Success) {
|
||||||
|
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactDetailUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.NotFound -> {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Contact not found",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.Success -> {
|
||||||
|
ContactDetailContent(
|
||||||
|
state = state,
|
||||||
|
onRemoveAddress = { viewModel.removeAddress(it) },
|
||||||
|
onSetDefault = { viewModel.setDefaultAddress(it) },
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
|
||||||
|
if (pendingDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
|
title = { Text("Delete contact") },
|
||||||
|
text = { Text("This contact and all their addresses will be permanently removed.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.cancelDelete() }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet ─────────────────────────────────────────────────
|
||||||
|
if (showAddAddressSheet) {
|
||||||
|
AddAddressSheet(
|
||||||
|
onSave = { type, address, label, makeDefault ->
|
||||||
|
viewModel.addAddress(type, address, label, makeDefault)
|
||||||
|
viewModel.closeAddAddressSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeAddAddressSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias sheet ──────────────────────────────────────────────────
|
||||||
|
if (showEditAliasSheet) {
|
||||||
|
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
||||||
|
?.data?.contact?.localAlias ?: ""
|
||||||
|
EditAliasSheet(
|
||||||
|
currentAlias = currentAlias,
|
||||||
|
onSave = { newAlias ->
|
||||||
|
viewModel.updateAlias(newAlias)
|
||||||
|
viewModel.closeEditAliasSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeEditAliasSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main content ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactDetailContent(
|
||||||
|
state: ContactDetailUiState.Success,
|
||||||
|
onRemoveAddress: (addressId: String) -> Unit,
|
||||||
|
onSetDefault: (PaymentAddressEntity) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val contact = state.data.contact
|
||||||
|
val addresses = state.data.addresses
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
|
||||||
|
) {
|
||||||
|
// ── Avatar + name hero ─────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "?"
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
contact.nip05?.let { nip05 ->
|
||||||
|
Text(
|
||||||
|
text = nip05,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses section ─────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Payment addresses") {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "No addresses added yet",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(addresses, key = { it.id }) { addr ->
|
||||||
|
AddressRow(
|
||||||
|
address = addr,
|
||||||
|
onRemove = { onRemoveAddress(addr.id) },
|
||||||
|
onSetDefault = { onSetDefault(addr) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
|
||||||
|
contact.about?.let { about ->
|
||||||
|
item {
|
||||||
|
DetailSection(title = "About") {
|
||||||
|
DetailRow(label = "Bio", value = about, maxLines = 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Address row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddressRow(
|
||||||
|
address: PaymentAddressEntity,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onSetDefault: () -> Unit
|
||||||
|
) {
|
||||||
|
val typeLabel = when (address.type) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL.name -> "LNURL"
|
||||||
|
PaymentAddressType.ON_CHAIN.name -> "On-chain"
|
||||||
|
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
|
||||||
|
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
|
||||||
|
PaymentAddressType.BIP353.name -> "BIP-353"
|
||||||
|
else -> address.type
|
||||||
|
}
|
||||||
|
val label = address.label?.let { "$typeLabel · $it" } ?: typeLabel
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = label,
|
||||||
|
value = address.address,
|
||||||
|
maxLines = 1,
|
||||||
|
trailingContent = {
|
||||||
|
Row {
|
||||||
|
// Set default star — filled if already default
|
||||||
|
IconButton(
|
||||||
|
onClick = onSetDefault,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (address.isDefault) Icons.Filled.Star
|
||||||
|
else Icons.Outlined.Star,
|
||||||
|
contentDescription = if (address.isDefault) "Default address"
|
||||||
|
else "Set as default",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Remove
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Remove address",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AddAddressSheet(
|
||||||
|
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf("") }
|
||||||
|
var label by remember { mutableStateOf("") }
|
||||||
|
var makeDefault by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val canSave = address.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { addressFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Type chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"Lightning Address" else "LNURL"
|
||||||
|
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"user@domain.com" else "LNURL1..."
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(addressFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional label
|
||||||
|
OutlinedTextField(
|
||||||
|
value = label,
|
||||||
|
onValueChange = { label = it },
|
||||||
|
label = { Text("Label (optional)") },
|
||||||
|
placeholder = { Text("e.g. personal, savings") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set as default toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set as default",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = makeDefault,
|
||||||
|
onCheckedChange = { makeDefault = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EditAliasSheet(
|
||||||
|
currentAlias: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var alias by remember { mutableStateOf(currentAlias) }
|
||||||
|
val canSave = alias.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Edit name", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = alias,
|
||||||
|
onValueChange = { alias = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(alias)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = { focusManager.clearFocus(); onSave(alias) },
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactDetailUiState {
|
||||||
|
data object Loading : ContactDetailUiState()
|
||||||
|
data object NotFound : ContactDetailUiState()
|
||||||
|
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactDetailViewModel(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
|
||||||
|
.map { contact ->
|
||||||
|
if (contact == null) ContactDetailUiState.NotFound
|
||||||
|
else ContactDetailUiState.Success(contact)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactDetailUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Delete contact ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _pendingDelete = MutableStateFlow(false)
|
||||||
|
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
|
||||||
|
|
||||||
|
fun requestDelete() { _pendingDelete.value = true }
|
||||||
|
fun cancelDelete() { _pendingDelete.value = false }
|
||||||
|
|
||||||
|
fun confirmDelete(onDeleted: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(contactId)
|
||||||
|
_pendingDelete.value = false
|
||||||
|
onDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun updateAlias(newAlias: String) {
|
||||||
|
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type,
|
||||||
|
address = address.trim(),
|
||||||
|
label = label?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
makeDefault = makeDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAddress(addressId: String) {
|
||||||
|
viewModelScope.launch { repo.removeAddress(addressId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAddress(address: PaymentAddressEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = PaymentAddressType.valueOf(address.type),
|
||||||
|
address = address.address,
|
||||||
|
label = address.label,
|
||||||
|
makeDefault = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet state ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showAddAddressSheet = MutableStateFlow(false)
|
||||||
|
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
|
||||||
|
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
|
||||||
|
|
||||||
|
// ── Edit alias sheet state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showEditAliasSheet = MutableStateFlow(false)
|
||||||
|
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
|
||||||
|
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactDetailViewModel(contactId, repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactPickerSheet(
|
||||||
|
contacts: List<ContactWithAddresses>,
|
||||||
|
onContactSelected: (contactId: String) -> Unit,
|
||||||
|
onCreateNew: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var search by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filtered = remember(contacts, search) {
|
||||||
|
if (search.isBlank()) contacts
|
||||||
|
else contacts.filter { c ->
|
||||||
|
val name = c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: ""
|
||||||
|
name.contains(search, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = search,
|
||||||
|
onValueChange = { search = it },
|
||||||
|
label = { Text("Search") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contact list
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 320.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
items(filtered, key = { it.contact.id }) { item ->
|
||||||
|
val displayName = item.contact.localAlias
|
||||||
|
?: item.contact.displayName
|
||||||
|
?: item.contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = item.addresses.firstOrNull()?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onContactSelected(item.contact.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.isEmpty() && search.isNotBlank()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "No contacts match \"$search\"",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new contact button
|
||||||
|
HorizontalDivider()
|
||||||
|
TextButton(
|
||||||
|
onClick = onCreateNew,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Create new contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen(
|
||||||
|
viewModel: ContactsViewModel,
|
||||||
|
onContactClick: (contactId: String) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var showCreateSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Contacts") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showCreateSheet = true }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactsUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactsUiState.Success -> {
|
||||||
|
if (state.contacts.isEmpty()) {
|
||||||
|
ContactsEmptyContent(
|
||||||
|
onAdd = { showCreateSheet = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(state.contacts, key = { it.contact.id }) { item ->
|
||||||
|
ContactRow(item, onClick = { onContactClick(item.contact.id) })
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateSheet) {
|
||||||
|
CreateContactSheet(
|
||||||
|
onSave = { name, addressType, address ->
|
||||||
|
viewModel.createContact(name, addressType, address)
|
||||||
|
showCreateSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsEmptyContent(
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tap + to add your first contact",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = onAdd) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(
|
||||||
|
item: ContactWithAddresses,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val contact = item.contact
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
val primaryAddress = item.addresses
|
||||||
|
.firstOrNull { it.isDefault }
|
||||||
|
?: item.addresses.firstOrNull()
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = primaryAddress?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactsUiState {
|
||||||
|
data object Loading : ContactsUiState()
|
||||||
|
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactsViewModel(
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
|
||||||
|
.map { ContactsUiState.Success(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactsUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createContact(
|
||||||
|
name: String,
|
||||||
|
addressType: PaymentAddressType?,
|
||||||
|
address: String?
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
repo.createContact(
|
||||||
|
localAlias = name.trim(),
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContact(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactsViewModel(repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreateContactSheet(
|
||||||
|
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
initialAddress: String? = null
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf(initialAddress ?: "") }
|
||||||
|
|
||||||
|
val nameFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val canSave = name.isNotBlank()
|
||||||
|
|
||||||
|
val addressLabel = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL"
|
||||||
|
else -> "Address"
|
||||||
|
}
|
||||||
|
val addressPlaceholder = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL1..."
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { nameFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "New contact",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(nameFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Address type toggle
|
||||||
|
Text(
|
||||||
|
text = "Payment address (optional)",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LNURL
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) {
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ fun HistoryScreen(
|
|||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
val availableTypes by vm.availableTypes.collectAsState()
|
val availableTypes by vm.availableTypes.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val contactNames by vm.contactNames.collectAsState()
|
||||||
|
|
||||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
val dismissFilterSheet = { filterSheetVisible = false }
|
val dismissFilterSheet = { filterSheetVisible = false }
|
||||||
@@ -234,6 +235,7 @@ fun HistoryScreen(
|
|||||||
payment = payment,
|
payment = payment,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
contactName = contactNames[payment.checkingId],
|
||||||
onClick = { onPaymentClick(payment) }
|
onClick = { onPaymentClick(payment) }
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
@@ -30,6 +34,7 @@ class HistoryViewModel(
|
|||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
val fiatCurrency : StateFlow<String?>,
|
val fiatCurrency : StateFlow<String?>,
|
||||||
val fiatSatsPerUnit : StateFlow<Double?>
|
val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -114,6 +119,23 @@ class HistoryViewModel(
|
|||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps contactId → display name (for filter chips)
|
||||||
|
val contactDisplayNames: StateFlow<Map<String, String>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.map { contacts ->
|
||||||
|
contacts.associate { c ->
|
||||||
|
c.contact.id to (c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: "Unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
// ── Room-backed query results ──────────────────────────────────────────────
|
// ── Room-backed query results ──────────────────────────────────────────────
|
||||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
||||||
|
|
||||||
@@ -151,6 +173,77 @@ class HistoryViewModel(
|
|||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.extra?.tag in f.types }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact name map ─────────────────────────────────────────────────────
|
||||||
|
// Maps checkingId → display name for any payment that has a linked contact.
|
||||||
|
// Rebuilt whenever the underlying tx_contact_links or contacts tables change.
|
||||||
|
val contactNames: StateFlow<Map<String, String>> =
|
||||||
|
contactRepo.observeAllPaymentContactNames()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Contact assignment ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
|
||||||
|
contactRepo.observeContactsForPayment(paymentHash)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun assignContact(paymentHash: String, contactId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unassignContact(paymentHash: String, contactId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepo.unlinkPaymentFromContact(paymentHash, contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveContactAndAssign(
|
||||||
|
paymentHash : String,
|
||||||
|
name : String,
|
||||||
|
addressType : PaymentAddressType?,
|
||||||
|
address : String?
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
val contact = contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleContactFilter(contactId: String) {
|
||||||
|
_filter.update { f ->
|
||||||
|
val updated = if (contactId in f.contactIds)
|
||||||
|
f.contactIds - contactId else f.contactIds + contactId
|
||||||
|
f.copy(contactIds = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun clearContactFilter() {
|
||||||
|
_filter.update { it.copy(contactIds = emptySet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
init {
|
init {
|
||||||
syncManager.init()
|
syncManager.init()
|
||||||
@@ -165,9 +258,55 @@ class HistoryViewModel(
|
|||||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
||||||
_roomResults.value = null
|
_roomResults.value = null
|
||||||
} else {
|
} else {
|
||||||
Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
// ── Room query path ───────────────────────────────────
|
||||||
"statuses=${f.statuses} dir=${f.direction} types=${f.types} ")
|
val baseResults = paymentCache.queryAll(f.searchQuery, f)
|
||||||
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
.toMutableList()
|
||||||
|
|
||||||
|
// ── Supplemental contact search ───────────────────────
|
||||||
|
// 1. Text query may match contact names not in payment_records
|
||||||
|
if (f.searchQuery.isNotBlank()) {
|
||||||
|
val contactCheckingIds = contactRepo
|
||||||
|
.findCheckingIdsByContactName(f.searchQuery)
|
||||||
|
if (contactCheckingIds.isNotEmpty()) {
|
||||||
|
val contactPayments = paymentCache
|
||||||
|
.queryByCheckingIds(contactCheckingIds)
|
||||||
|
// Merge without duplicates
|
||||||
|
val existingIds = baseResults.map { it.checkingId }.toSet()
|
||||||
|
baseResults += contactPayments
|
||||||
|
.filter { it.checkingId !in existingIds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Explicit contact ID filter
|
||||||
|
if (f.contactIds.isNotEmpty()) {
|
||||||
|
val contactCheckingIds = contactRepo
|
||||||
|
.findCheckingIdsByContactIds(f.contactIds.toList())
|
||||||
|
if (contactCheckingIds.isNotEmpty()) {
|
||||||
|
// Intersect: only keep base results that belong
|
||||||
|
// to the selected contacts, OR fetch them directly
|
||||||
|
val existingIds = baseResults.map { it.checkingId }.toSet()
|
||||||
|
val missing = contactCheckingIds.filter { it !in existingIds }
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
baseResults += paymentCache.queryByCheckingIds(missing)
|
||||||
|
}
|
||||||
|
// Now narrow: remove payments NOT linked to selected contacts
|
||||||
|
val contactCheckingIdSet = contactCheckingIds.toSet()
|
||||||
|
_roomResults.value = baseResults
|
||||||
|
.filter { it.checkingId in contactCheckingIdSet }
|
||||||
|
.also {
|
||||||
|
Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results")
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
} else {
|
||||||
|
// Selected contacts have no payments — return empty
|
||||||
|
_roomResults.value = emptyList()
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_roomResults.value = baseResults.also {
|
||||||
|
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results (${it.size - (paymentCache.queryAll(f.searchQuery, f).size)} from contacts)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +325,7 @@ class HistoryViewModelFactory(
|
|||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
private val fiatCurrency : StateFlow<String?>,
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
@@ -195,6 +335,7 @@ class HistoryViewModelFactory(
|
|||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
) as T
|
) as T
|
||||||
|
|||||||
+211
-69
@@ -6,23 +6,29 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,8 +38,10 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
@@ -41,20 +49,24 @@ import androidx.compose.ui.platform.LocalClipboard
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.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.CopyIconButton
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
|
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── Detail screen ────────────────────────────────────────────────────────────
|
// ── Detail screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,8 +75,8 @@ fun PaymentDetailScreen(
|
|||||||
vm : HistoryViewModel,
|
vm : HistoryViewModel,
|
||||||
onBack : () -> Unit
|
onBack : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val detailState by vm.detailState.collectAsState()
|
val detailState by vm.detailState.collectAsState()
|
||||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
|
||||||
@@ -76,14 +88,31 @@ fun PaymentDetailScreen(
|
|||||||
val detail = s.detail.details
|
val detail = s.detail.details
|
||||||
when {
|
when {
|
||||||
detail == null -> payment
|
detail == null -> payment
|
||||||
else -> detail.copy(
|
else -> detail.copy(
|
||||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DetailState.Partial -> s.record
|
is DetailState.Partial -> s.record
|
||||||
else -> payment
|
else -> payment
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact state ─────────────────────────────────────────────────────────
|
||||||
|
val linkedContacts by remember(payment.checkingId) {
|
||||||
|
vm.contactsForPayment(payment.checkingId)
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var showContactPicker by remember { mutableStateOf(false) }
|
||||||
|
var showCreateContact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Best-effort lnAddress to pre-fill CreateContactSheet:
|
||||||
|
// prefer extra.comment if it looks like a Lightning Address, else null
|
||||||
|
val paymentLnAddress = remember(enriched) {
|
||||||
|
enriched.extra?.comment
|
||||||
|
?.takeIf { it.contains("@") && !it.contains(" ") }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -91,15 +120,15 @@ fun PaymentDetailScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
if (payment.isOutgoing) strings.history.detailOutgoingTitle // ← "Outgoing payment"
|
if (payment.isOutgoing) strings.history.detailOutgoingTitle
|
||||||
else strings.history.detailIncomingTitle // ← "Incoming payment"
|
else strings.history.detailIncomingTitle
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = strings.history.detailBack // ← "Back"
|
contentDescription = strings.history.detailBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,8 +138,8 @@ fun PaymentDetailScreen(
|
|||||||
when (detailState) {
|
when (detailState) {
|
||||||
is DetailState.Loading -> {
|
is DetailState.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +150,7 @@ fun PaymentDetailScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.history.detailCouldNotLoad, // ← "Could not load full details"
|
text = strings.history.detailCouldNotLoad,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
@@ -130,33 +159,78 @@ fun PaymentDetailScreen(
|
|||||||
PaymentDetailContent(
|
PaymentDetailContent(
|
||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(payment.checkingId, contactId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
PaymentDetailContent(
|
PaymentDetailContent(
|
||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
modifier = Modifier.padding(padding)
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(payment.checkingId, contactId)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact picker sheet ──────────────────────────────────────────────────
|
||||||
|
if (showContactPicker) {
|
||||||
|
ContactPickerSheet(
|
||||||
|
contacts = allContacts,
|
||||||
|
onContactSelected = { contactId ->
|
||||||
|
vm.assignContact(payment.checkingId, contactId)
|
||||||
|
showContactPicker = false
|
||||||
|
},
|
||||||
|
onCreateNew = {
|
||||||
|
showContactPicker = false
|
||||||
|
showCreateContact = true
|
||||||
|
},
|
||||||
|
onDismiss = { showContactPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create & assign contact sheet ─────────────────────────────────────────
|
||||||
|
if (showCreateContact) {
|
||||||
|
val addressType = if (paymentLnAddress != null)
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddressType = addressType,
|
||||||
|
initialAddress = paymentLnAddress,
|
||||||
|
onSave = { name, type, address ->
|
||||||
|
vm.saveContactAndAssign(payment.checkingId, name, type, address)
|
||||||
|
showCreateContact = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateContact = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail content ───────────────────────────────────────────────────────────
|
// ── Detail content ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaymentDetailContent(
|
private fun PaymentDetailContent(
|
||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
modifier : Modifier = Modifier
|
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||||
|
onAssignContact : () -> Unit,
|
||||||
|
onUnassignContact : (contactId: String) -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -178,45 +252,42 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// ── Hero amount ──────────────────────────────────────────────────────
|
// ── Hero amount ────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
else Icons.Default.ArrowDownward,
|
else Icons.Default.ArrowDownward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = amountColor,
|
tint = amountColor,
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
AmountWithFiatColumn(
|
AmountWithFiatColumn(
|
||||||
amountSat = payment.amountSat,
|
amountSat = payment.amountSat,
|
||||||
isOutgoing = payment.isOutgoing,
|
isOutgoing = payment.isOutgoing,
|
||||||
amountColor = amountColor,
|
amountColor = amountColor,
|
||||||
fiatLine = heroFiat,
|
fiatLine = heroFiat,
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
)
|
)
|
||||||
if (payment.feeSat > 0) {
|
if (payment.feeSat > 0) {
|
||||||
val ppm = feePpm(
|
val ppm = feePpm(
|
||||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||||
)
|
)
|
||||||
// Build the optional parts separately so the lambda stays clean
|
|
||||||
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
||||||
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
||||||
val feeLabel = strings.history.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
|
val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
|
||||||
payment.feeSat, fiatPart, ppmPart
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = feeLabel,
|
text = feeLabel,
|
||||||
@@ -230,47 +301,118 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Basic info ───────────────────────────────────────────────────────
|
// ── Contact section ────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details"
|
DetailSection(title = "Contact") {
|
||||||
DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date"
|
if (linkedContacts.isEmpty()) {
|
||||||
payment.createdAt,
|
// No contact linked — show assign button
|
||||||
payment.time,
|
OutlinedButton(
|
||||||
strings.today,
|
onClick = onAssignContact,
|
||||||
strings.yesterday))
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Assign contact")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show each linked contact with a remove button
|
||||||
|
linkedContacts.forEach { contact ->
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
DetailRow(
|
||||||
|
label = "Contact",
|
||||||
|
value = name,
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onUnassignContact(contact.id) },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Remove contact",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Allow assigning additional contacts
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onAssignContact) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add another",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic info ─────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.history.detailSectionDetails) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
strings.history.detailRowMemo, // ← "Memo"
|
strings.history.detailRowDate,
|
||||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
|
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
DetailRow(
|
||||||
|
strings.history.detailRowMemo,
|
||||||
|
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
|
||||||
)
|
)
|
||||||
if (payment.extra?.tag != null) {
|
if (payment.extra?.tag != null) {
|
||||||
DetailRow(strings.history.detailRowType, payment.extra.tag) // ← "Type"
|
DetailRow(strings.history.detailRowType, payment.extra.tag)
|
||||||
}
|
}
|
||||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||||
DetailRow(strings.history.detailRowComment, payment.extra.comment) // ← "Comment"
|
DetailRow(strings.history.detailRowComment, payment.extra.comment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
// ── Success action (LNURL) ─────────────────────────────────────────────
|
||||||
payment.extra?.successAction?.let { action ->
|
payment.extra?.successAction?.let { action ->
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionSuccessAction) { // ← "Success Action"
|
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||||
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } // ← "Message"
|
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||||
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } // ← "URL"
|
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||||
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } // ← "Description"
|
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Technical ────────────────────────────────────────────────────────
|
// ── Technical ─────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionTechnical) { // ← "Technical"
|
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowPaymentHash, // ← "Payment Hash"
|
label = strings.history.detailRowPaymentHash,
|
||||||
value = payment.paymentHash,
|
value = payment.paymentHash,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
||||||
@@ -280,11 +422,11 @@ private fun PaymentDetailContent(
|
|||||||
)
|
)
|
||||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowPreimage, // ← "Preimage"
|
label = strings.history.detailRowPreimage,
|
||||||
value = payment.preimage,
|
value = payment.preimage,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
||||||
@@ -297,7 +439,7 @@ private fun PaymentDetailContent(
|
|||||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||||
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowDestination, // ← "Destination"
|
label = strings.history.detailRowDestination,
|
||||||
value = label,
|
value = label,
|
||||||
valueColor = MaterialTheme.colorScheme.primary,
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||||
@@ -311,12 +453,12 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
if (!payment.bolt11.isNullOrBlank()) {
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowBolt11, // ← "BOLT11"
|
label = strings.history.detailRowBolt11,
|
||||||
value = payment.bolt11,
|
value = payment.bolt11,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ data class PaymentFilter(
|
|||||||
val maxAmountSat : Long? = null,
|
val maxAmountSat : Long? = null,
|
||||||
val minCreatedAt : Long? = null,
|
val minCreatedAt : Long? = null,
|
||||||
val maxCreatedAt : Long? = null,
|
val maxCreatedAt : Long? = null,
|
||||||
val datePreset : DatePreset? = null
|
val datePreset : DatePreset? = null,
|
||||||
|
val contactIds : Set<String> = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||||
@@ -33,8 +34,11 @@ val PaymentFilter.isActive: Boolean
|
|||||||
fun PaymentFilter.needsRoomQuery(): Boolean =
|
fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||||
searchQuery.isNotBlank() ||
|
searchQuery.isNotBlank() ||
|
||||||
statuses.isNotEmpty() ||
|
statuses.isNotEmpty() ||
|
||||||
minAmountSat != null || maxAmountSat != null ||
|
minAmountSat != null ||
|
||||||
minCreatedAt != null || maxCreatedAt != null
|
maxAmountSat != null ||
|
||||||
|
minCreatedAt != null ||
|
||||||
|
maxCreatedAt != null ||
|
||||||
|
contactIds.isNotEmpty()
|
||||||
|
|
||||||
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
||||||
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ internal fun PaymentRow(
|
|||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
|
contactName : String? = null,
|
||||||
onClick : () -> Unit
|
onClick : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -70,8 +71,9 @@ internal fun PaymentRow(
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = payment.memo?.takeIf { it.isNotBlank() }
|
text = contactName // ← contact name first
|
||||||
?: strings.history.paymentRowNoMemo, // ← "No memo"
|
?: payment.memo?.takeIf { it.isNotBlank() }
|
||||||
|
?: strings.history.paymentRowNoMemo,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.LockStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as FragmentActivity
|
val activity = context as FragmentActivity
|
||||||
|
val strings = LocalAppStrings.current.lock
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
showBiometricPrompt(activity, onUnlocked)
|
showBiometricPrompt(activity, onUnlocked, strings)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold { innerPadding ->
|
Scaffold { innerPadding ->
|
||||||
@@ -47,7 +50,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Gudari Wallet",
|
text = strings.appName,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
@@ -56,7 +59,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Authenticate to continue",
|
text = strings.authenticateToContinue,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
@@ -65,7 +68,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
|||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { showBiometricPrompt(activity, onUnlocked) }
|
onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Fingerprint,
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
@@ -73,7 +76,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
|||||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
Text("Unlock")
|
Text(strings.unlockButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,8 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
|||||||
|
|
||||||
private fun showBiometricPrompt(
|
private fun showBiometricPrompt(
|
||||||
activity: FragmentActivity,
|
activity: FragmentActivity,
|
||||||
onUnlocked: () -> Unit
|
onUnlocked: () -> Unit,
|
||||||
|
strings: LockStrings
|
||||||
) {
|
) {
|
||||||
val executor = ContextCompat.getMainExecutor(activity)
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
|
||||||
@@ -95,8 +99,8 @@ private fun showBiometricPrompt(
|
|||||||
val prompt = BiometricPrompt(activity, executor, callback)
|
val prompt = BiometricPrompt(activity, executor, callback)
|
||||||
|
|
||||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
.setTitle("Unlock Gudari Wallet")
|
.setTitle(strings.biometricPromptTitle)
|
||||||
.setSubtitle("Authenticate to access your wallet")
|
.setSubtitle(strings.biometricPromptSubtitle)
|
||||||
.setAllowedAuthenticators(
|
.setAllowedAuthenticators(
|
||||||
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||||
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BiometricLockPage(
|
fun BiometricLockPage(
|
||||||
appLockEnabled: Boolean,
|
appLockEnabled: Boolean,
|
||||||
onAppLockChanged: (Boolean) -> Unit
|
onAppLockChanged: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val strings = LocalAppStrings.current.lock
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -32,7 +34,7 @@ fun BiometricLockPage(
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "App Lock",
|
text = strings.appLockTitle,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
@@ -40,7 +42,7 @@ fun BiometricLockPage(
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
|
text = strings.appLockDescription,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -63,11 +65,12 @@ fun BiometricLockPage(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Lock app on open",
|
text = strings.appLockToggleLabel,
|
||||||
style = MaterialTheme.typography.bodyLarge
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (appLockEnabled) "Enabled (recommended)" else "Disabled",
|
text = if (appLockEnabled) strings.appLockToggleEnabled
|
||||||
|
else strings.appLockToggleDisabled,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import androidx.compose.foundation.layout.Column
|
|||||||
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.size
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.SuggestionChipDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -17,6 +23,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
@@ -25,6 +32,10 @@ import com.bitcointxoko.gudariwallet.util.fiatLabel
|
|||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
||||||
import com.bitcointxoko.gudariwallet.util.satsToFiat
|
import com.bitcointxoko.gudariwallet.util.satsToFiat
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LnurlPayForm(
|
internal fun LnurlPayForm(
|
||||||
@@ -32,7 +43,7 @@ internal fun LnurlPayForm(
|
|||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
fiatRate : Double?,
|
fiatRate : Double?,
|
||||||
fiatCurrency: String?,
|
fiatCurrency: String?,
|
||||||
sendStrings: SendStrings
|
sendStrings : SendStrings
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
@@ -40,84 +51,120 @@ internal fun LnurlPayForm(
|
|||||||
var comment by remember { mutableStateOf("") }
|
var comment by remember { mutableStateOf("") }
|
||||||
val isFixed = state.minSats == state.maxSats
|
val isFixed = state.minSats == state.maxSats
|
||||||
|
|
||||||
// Long? — null means the field is empty or non-numeric, not zero.
|
|
||||||
val sats: Long? = amount.toLongOrNull()
|
val sats: Long? = amount.toLongOrNull()
|
||||||
|
|
||||||
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)
|
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
|
||||||
|
|
||||||
|
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
|
||||||
|
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
|
||||||
|
var showSaveContact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// ── Derive address type directly from the lnurl string ───────────────
|
||||||
|
val addressType = remember(state.lnurl) {
|
||||||
|
when (SendInputDetector.detect(state.lnurl)) {
|
||||||
|
SendInputType.LightningAddress -> PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
else -> PaymentAddressType.LNURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text(
|
// ── Recipient header ──────────────────────────────────────────────
|
||||||
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X"
|
if (existingContact != null) {
|
||||||
style = MaterialTheme.typography.titleMedium
|
// Known contact — show their name instead of the raw address
|
||||||
)
|
val contactName = existingContact!!.localAlias
|
||||||
|
?: existingContact!!.displayName
|
||||||
|
?: existingContact!!.name
|
||||||
|
?: payingToLabel(state.lnurl, state.domain)
|
||||||
|
Text(
|
||||||
|
text = strings.payingTo(contactName),
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Unknown — show domain/address as before
|
||||||
|
Text(
|
||||||
|
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.description.isNotBlank()) {
|
if (state.description.isNotBlank()) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(state.description, style = MaterialTheme.typography.bodySmall)
|
Text(state.description, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── "Add to contacts" chip — only when no existing contact ───────
|
||||||
|
if (existingContact == null) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { showSaveContact = true },
|
||||||
|
label = { Text("Add to contacts") },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ── Amount field — unchanged ──────────────────────────────────────
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = amount,
|
value = amount,
|
||||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
||||||
label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
|
label = { Text(strings.receive.amountInSats) },
|
||||||
isError = amount.isNotBlank() && !isAmountValid,
|
isError = amount.isNotBlank() && !isAmountValid,
|
||||||
supportingText = {
|
supportingText = {
|
||||||
when {
|
when {
|
||||||
// Error: typed something out of range
|
|
||||||
amount.isNotBlank() && !isAmountValid -> {
|
amount.isNotBlank() && !isAmountValid -> {
|
||||||
Text(
|
Text(
|
||||||
text = strings.lnurlAmountError( // ← "Enter an amount between X and Y sats"
|
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
||||||
state.minSats,
|
|
||||||
state.maxSats
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Fiat range hint when rate is available
|
|
||||||
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)
|
||||||
if (isFixed)
|
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
||||||
Text(strings.lnurlFiatRange(minFiat, maxFiat)) // ← "X – Y EUR" (fixed amount)
|
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
||||||
else
|
|
||||||
Text( // ← "X – Y sats · A – B EUR"
|
|
||||||
strings.lnurlSatsAndFiatRange(
|
|
||||||
state.minSats, state.maxSats,
|
|
||||||
minFiat, maxFiat
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Sats-only range when no fiat rate
|
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
||||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X – Y sats"
|
else -> {}
|
||||||
else -> {}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isFixed,
|
enabled = !isFixed,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Comment field — unchanged ─────────────────────────────────────
|
||||||
if (state.commentAllowed > 0) {
|
if (state.commentAllowed > 0) {
|
||||||
Spacer(Modifier.height(8.dp))
|
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 },
|
||||||
label = { Text(strings.commentOptional(state.commentAllowed)) }, // ← "Comment (optional, max X chars)"
|
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
||||||
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) }, // ← "X/Y"
|
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ── Pay button — unchanged ────────────────────────────────────────
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -125,9 +172,23 @@ internal fun LnurlPayForm(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) // ← "Pay X sats · Y EUR"
|
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
|
||||||
else strings.lnurlPayButton(amount) // ← "Pay X sats"
|
else strings.lnurlPayButton(amount)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CreateContactSheet — pre-filled with resolved address ─────────────
|
||||||
|
if (showSaveContact) {
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddressType = addressType,
|
||||||
|
initialAddress = state.lnurl,
|
||||||
|
onSave = { name, type, address ->
|
||||||
|
vm.saveContactFromSend(name, type, address)
|
||||||
|
showSaveContact = false
|
||||||
|
},
|
||||||
|
onDismiss = { showSaveContact = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user