Compare commits
4 Commits
| 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()
|
||||
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,
|
||||
NodeAliasCacheEntity::class,
|
||||
NwcKeyEntity::class,
|
||||
NwcBudgetEntity::class
|
||||
NwcBudgetEntity::class,
|
||||
ContactEntity::class,
|
||||
PaymentAddressEntity::class,
|
||||
TxContactCrossRef::class,
|
||||
],
|
||||
version = 7,
|
||||
version = 9,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(PaymentConverters::class)
|
||||
@@ -26,6 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun lnurlCacheDao(): LnurlCacheDao
|
||||
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
||||
abstract fun nwcKeyDao(): NwcKeyDao
|
||||
abstract fun contactDao(): ContactDao
|
||||
|
||||
companion object {
|
||||
@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 =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
@@ -137,7 +208,9 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9
|
||||
)
|
||||
.build()
|
||||
.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")
|
||||
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")
|
||||
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",
|
||||
failedToDeleteConnection = "Failed to delete 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",
|
||||
failedToDeleteConnection = "Error al eliminar 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",
|
||||
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
|
||||
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 receive: ReceiveStrings,
|
||||
val history: HistoryStrings,
|
||||
val nwc: NwcStrings
|
||||
val nwc: NwcStrings,
|
||||
val lock: LockStrings
|
||||
)
|
||||
|
||||
data class OnboardingStrings(
|
||||
@@ -480,3 +481,16 @@ data class NwcStrings(
|
||||
val failedToDeleteConnection : 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.KeyboardOptions
|
||||
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.platform.LocalFocusManager
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||
@@ -40,6 +41,7 @@ fun HomeTab(
|
||||
vm: WalletViewModel,
|
||||
onHistoryClick: () -> Unit,
|
||||
onNwcClick : () -> Unit,
|
||||
onContactsClick: () -> Unit,
|
||||
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
@@ -284,21 +286,27 @@ fun HomeTab(
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── NWC button — top-right corner ─────────────────────────────────
|
||||
IconButton(
|
||||
onClick = onNwcClick,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onContactsClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.People,
|
||||
contentDescription = "Contacts",
|
||||
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)
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── NEW: Language switcher composable ─────────────────────────────────────────
|
||||
|
||||
@@ -57,6 +57,8 @@ import cafe.adriel.lyricist.Lyricist
|
||||
import com.bitcointxoko.gudariwallet.MainActivity
|
||||
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||
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.HistoryViewModel
|
||||
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.SendStrings
|
||||
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 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_NWC = "nwc"
|
||||
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||
private const val ROUTE_CONTACTS = "contacts"
|
||||
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||
|
||||
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
@@ -122,6 +130,7 @@ fun WalletScreen(
|
||||
repo = vm.repo,
|
||||
aliasRepo = vm.aliasRepo,
|
||||
paymentCache = vm.paymentCache,
|
||||
contactRepo = vm.contactRepo,
|
||||
fiatCurrency = vm.selectedCurrency,
|
||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||
)
|
||||
@@ -130,6 +139,9 @@ fun WalletScreen(
|
||||
val nwcVm: NwcViewModel = viewModel(
|
||||
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 currentRoute = navBackStackEntry?.destination?.route
|
||||
@@ -139,12 +151,16 @@ fun WalletScreen(
|
||||
&& currentRoute != ROUTE_HISTORY
|
||||
&& currentRoute != ROUTE_NWC
|
||||
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
||||
&& currentRoute != ROUTE_CONTACTS
|
||||
&& currentRoute != ROUTE_CONTACT_DETAIL
|
||||
|
||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||
|| currentRoute == ROUTE_SCANNER
|
||||
|| currentRoute == ROUTE_NWC
|
||||
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
||||
|| currentRoute == ROUTE_CONTACTS
|
||||
|| currentRoute == ROUTE_CONTACT_DETAIL
|
||||
|
||||
// ── Notification tap ──────────────────────────────────────────────────────
|
||||
val hash = pendingPaymentHash.value
|
||||
@@ -341,6 +357,9 @@ fun WalletScreen(
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onContactsClick = {
|
||||
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
|
||||
},
|
||||
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 ────
|
||||
|
||||
@@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||
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.clipboard.ClipboardViewModel
|
||||
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.SendViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
private const val TAG = "WalletViewModel"
|
||||
@@ -30,6 +34,7 @@ class WalletViewModel(
|
||||
val aliasRepo : NodeAliasRepository,
|
||||
val paymentCache: PaymentCacheRepository,
|
||||
val nwcCache : NwcCacheRepository,
|
||||
val contactRepo : ContactRepository,
|
||||
private val balancePrefs: BalancePrefsStore,
|
||||
private val lnAddressStore : LightningAddressStore,
|
||||
val balanceVm : BalanceViewModel,
|
||||
@@ -126,6 +131,32 @@ class WalletViewModel(
|
||||
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
||||
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 {
|
||||
observePaymentEvents()
|
||||
fetchLightningAddress()
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||
@@ -37,6 +38,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
||||
val poller = PaymentPoller(repo)
|
||||
val paymentCache = PaymentCacheRepository(app)
|
||||
val nwcCache = NwcCacheRepository(app)
|
||||
val contactRepo = ContactRepository(app)
|
||||
val fiatDataStore = FiatDataStore(app)
|
||||
val balancePrefs = BalancePrefsStore(app)
|
||||
val lnAddressStore = LightningAddressStore(app)
|
||||
@@ -65,6 +67,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
||||
aliasRepo = aliasRepo,
|
||||
paymentCache = paymentCache,
|
||||
nwcCache = nwcCache,
|
||||
contactRepo = contactRepo,
|
||||
balancePrefs = balancePrefs,
|
||||
balanceVm = balanceVm,
|
||||
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 availableTypes by vm.availableTypes.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val contactNames by vm.contactNames.collectAsState()
|
||||
|
||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val dismissFilterSheet = { filterSheetVisible = false }
|
||||
@@ -234,6 +235,7 @@ fun HistoryScreen(
|
||||
payment = payment,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
contactName = contactNames[payment.checkingId],
|
||||
onClick = { onPaymentClick(payment) }
|
||||
)
|
||||
HorizontalDivider(
|
||||
|
||||
@@ -5,9 +5,13 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.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.ui.DetailState
|
||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||
@@ -30,6 +34,7 @@ class HistoryViewModel(
|
||||
private val repo : WalletRepository,
|
||||
private val aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val contactRepo : ContactRepository,
|
||||
val fiatCurrency : StateFlow<String?>,
|
||||
val fiatSatsPerUnit : StateFlow<Double?>
|
||||
) : ViewModel() {
|
||||
@@ -114,6 +119,23 @@ class HistoryViewModel(
|
||||
?.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 ──────────────────────────────────────────────
|
||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
||||
|
||||
@@ -151,6 +173,77 @@ class HistoryViewModel(
|
||||
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 {
|
||||
syncManager.init()
|
||||
@@ -165,9 +258,55 @@ class HistoryViewModel(
|
||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
||||
_roomResults.value = null
|
||||
} else {
|
||||
Timber.d("%snull", "FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
||||
"statuses=${f.statuses} dir=${f.direction} types=${f.types} ")
|
||||
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
||||
// ── Room query path ───────────────────────────────────
|
||||
val baseResults = 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 aliasRepo : NodeAliasRepository,
|
||||
private val paymentCache : PaymentCacheRepository,
|
||||
private val contactRepo : ContactRepository,
|
||||
private val fiatCurrency : StateFlow<String?>,
|
||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||
) : ViewModelProvider.Factory {
|
||||
@@ -195,6 +335,7 @@ class HistoryViewModelFactory(
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
paymentCache = paymentCache,
|
||||
contactRepo = contactRepo,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
) as T
|
||||
|
||||
+177
-35
@@ -6,23 +6,29 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,8 +38,10 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
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.api.PaymentRecord
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||
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.formatFiatForSats
|
||||
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ── Detail screen ────────────────────────────────────────────────────────────
|
||||
// ── Detail screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -86,20 +98,37 @@ fun PaymentDetailScreen(
|
||||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (payment.isOutgoing) strings.history.detailOutgoingTitle // ← "Outgoing payment"
|
||||
else strings.history.detailIncomingTitle // ← "Incoming payment"
|
||||
if (payment.isOutgoing) strings.history.detailOutgoingTitle
|
||||
else strings.history.detailIncomingTitle
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = strings.history.detailBack // ← "Back"
|
||||
contentDescription = strings.history.detailBack
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +150,7 @@ fun PaymentDetailScreen(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = strings.history.detailCouldNotLoad, // ← "Could not load full details"
|
||||
text = strings.history.detailCouldNotLoad,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
@@ -130,7 +159,12 @@ fun PaymentDetailScreen(
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
linkedContacts = linkedContacts,
|
||||
onAssignContact = { showContactPicker = true },
|
||||
onUnassignContact = { contactId ->
|
||||
vm.unassignContact(payment.checkingId, contactId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -140,20 +174,60 @@ fun PaymentDetailScreen(
|
||||
payment = enriched,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
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
|
||||
private fun PaymentDetailContent(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||
onAssignContact : () -> Unit,
|
||||
onUnassignContact : (contactId: String) -> Unit,
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
@@ -183,7 +257,7 @@ private fun PaymentDetailContent(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
// ── Hero amount ──────────────────────────────────────────────────────
|
||||
// ── Hero amount ────────────────────────────────────────────────────────
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
@@ -211,12 +285,9 @@ private fun PaymentDetailContent(
|
||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||
)
|
||||
// Build the optional parts separately so the lambda stays clean
|
||||
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
||||
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
||||
val feeLabel = strings.history.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
|
||||
payment.feeSat, fiatPart, ppmPart
|
||||
)
|
||||
val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = feeLabel,
|
||||
@@ -230,43 +301,114 @@ private fun PaymentDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Basic info ───────────────────────────────────────────────────────
|
||||
// ── Contact section ────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details"
|
||||
DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date"
|
||||
payment.createdAt,
|
||||
payment.time,
|
||||
strings.today,
|
||||
strings.yesterday))
|
||||
DetailSection(title = "Contact") {
|
||||
if (linkedContacts.isEmpty()) {
|
||||
// No contact linked — show assign button
|
||||
OutlinedButton(
|
||||
onClick = onAssignContact,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Assign contact")
|
||||
}
|
||||
} else {
|
||||
// Show each linked contact with a remove button
|
||||
linkedContacts.forEach { contact ->
|
||||
val name = contact.localAlias
|
||||
?: contact.displayName
|
||||
?: contact.name
|
||||
?: "Unknown"
|
||||
DetailRow(
|
||||
strings.history.detailRowMemo, // ← "Memo"
|
||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—"
|
||||
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(
|
||||
strings.history.detailRowDate,
|
||||
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) {
|
||||
DetailRow(strings.history.detailRowType, payment.extra.tag) // ← "Type"
|
||||
DetailRow(strings.history.detailRowType, payment.extra.tag)
|
||||
}
|
||||
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 ->
|
||||
item {
|
||||
DetailSection(title = strings.history.detailSectionSuccessAction) { // ← "Success Action"
|
||||
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } // ← "Message"
|
||||
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } // ← "URL"
|
||||
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } // ← "Description"
|
||||
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Technical ────────────────────────────────────────────────────────
|
||||
// ── Technical ─────────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = strings.history.detailSectionTechnical) { // ← "Technical"
|
||||
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||
DetailRow(
|
||||
label = strings.history.detailRowPaymentHash, // ← "Payment Hash"
|
||||
label = strings.history.detailRowPaymentHash,
|
||||
value = payment.paymentHash,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
@@ -280,7 +422,7 @@ private fun PaymentDetailContent(
|
||||
)
|
||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||
DetailRow(
|
||||
label = strings.history.detailRowPreimage, // ← "Preimage"
|
||||
label = strings.history.detailRowPreimage,
|
||||
value = payment.preimage,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
@@ -297,7 +439,7 @@ private fun PaymentDetailContent(
|
||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||
DetailRow(
|
||||
label = strings.history.detailRowDestination, // ← "Destination"
|
||||
label = strings.history.detailRowDestination,
|
||||
value = label,
|
||||
valueColor = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||
@@ -311,7 +453,7 @@ private fun PaymentDetailContent(
|
||||
}
|
||||
if (!payment.bolt11.isNullOrBlank()) {
|
||||
DetailRow(
|
||||
label = strings.history.detailRowBolt11, // ← "BOLT11"
|
||||
label = strings.history.detailRowBolt11,
|
||||
value = payment.bolt11,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
|
||||
@@ -14,7 +14,8 @@ data class PaymentFilter(
|
||||
val maxAmountSat : Long? = null,
|
||||
val minCreatedAt : 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 }
|
||||
@@ -33,8 +34,11 @@ val PaymentFilter.isActive: Boolean
|
||||
fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||
searchQuery.isNotBlank() ||
|
||||
statuses.isNotEmpty() ||
|
||||
minAmountSat != null || maxAmountSat != null ||
|
||||
minCreatedAt != null || maxCreatedAt != null
|
||||
minAmountSat != null ||
|
||||
maxAmountSat != null ||
|
||||
minCreatedAt != null ||
|
||||
maxCreatedAt != null ||
|
||||
contactIds.isNotEmpty()
|
||||
|
||||
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
||||
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
||||
|
||||
@@ -36,6 +36,7 @@ internal fun PaymentRow(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
contactName : String? = null,
|
||||
onClick : () -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
@@ -70,8 +71,9 @@ internal fun PaymentRow(
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = payment.memo?.takeIf { it.isNotBlank() }
|
||||
?: strings.history.paymentRowNoMemo, // ← "No memo"
|
||||
text = contactName // ← contact name first
|
||||
?: payment.memo?.takeIf { it.isNotBlank() }
|
||||
?: strings.history.paymentRowNoMemo,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
||||
@@ -15,14 +15,17 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.i18n.LockStrings
|
||||
|
||||
@Composable
|
||||
fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as FragmentActivity
|
||||
val strings = LocalAppStrings.current.lock
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
showBiometricPrompt(activity, onUnlocked)
|
||||
showBiometricPrompt(activity, onUnlocked, strings)
|
||||
}
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
@@ -47,7 +50,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Gudari Wallet",
|
||||
text = strings.appName,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -56,7 +59,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Authenticate to continue",
|
||||
text = strings.authenticateToContinue,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
@@ -65,7 +68,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = { showBiometricPrompt(activity, onUnlocked) }
|
||||
onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Fingerprint,
|
||||
@@ -73,7 +76,7 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||
)
|
||||
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text("Unlock")
|
||||
Text(strings.unlockButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,8 @@ fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||
|
||||
private fun showBiometricPrompt(
|
||||
activity: FragmentActivity,
|
||||
onUnlocked: () -> Unit
|
||||
onUnlocked: () -> Unit,
|
||||
strings: LockStrings
|
||||
) {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
|
||||
@@ -95,8 +99,8 @@ private fun showBiometricPrompt(
|
||||
val prompt = BiometricPrompt(activity, executor, callback)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Unlock Gudari Wallet")
|
||||
.setSubtitle("Authenticate to access your wallet")
|
||||
.setTitle(strings.biometricPromptTitle)
|
||||
.setSubtitle(strings.biometricPromptSubtitle)
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
|
||||
@@ -9,12 +9,14 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
|
||||
@Composable
|
||||
fun BiometricLockPage(
|
||||
appLockEnabled: Boolean,
|
||||
onAppLockChanged: (Boolean) -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current.lock
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -32,7 +34,7 @@ fun BiometricLockPage(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "App Lock",
|
||||
text = strings.appLockTitle,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -40,7 +42,7 @@ fun BiometricLockPage(
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
|
||||
text = strings.appLockDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -63,11 +65,12 @@ fun BiometricLockPage(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Lock app on open",
|
||||
text = strings.appLockToggleLabel,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = if (appLockEnabled) "Enabled (recommended)" else "Disabled",
|
||||
text = if (appLockEnabled) strings.appLockToggleEnabled
|
||||
else strings.appLockToggleDisabled,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.SuggestionChipDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -17,6 +23,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||
@@ -25,6 +32,10 @@ import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
||||
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
|
||||
internal fun LnurlPayForm(
|
||||
@@ -32,7 +43,7 @@ internal fun LnurlPayForm(
|
||||
vm : WalletViewModel,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency: String?,
|
||||
sendStrings: SendStrings
|
||||
sendStrings : SendStrings
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
|
||||
@@ -40,55 +51,86 @@ internal fun LnurlPayForm(
|
||||
var comment by remember { mutableStateOf("") }
|
||||
val isFixed = state.minSats == state.maxSats
|
||||
|
||||
// Long? — null means the field is empty or non-numeric, not zero.
|
||||
val sats: Long? = amount.toLongOrNull()
|
||||
|
||||
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
|
||||
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 {
|
||||
// ── Recipient header ──────────────────────────────────────────────
|
||||
if (existingContact != null) {
|
||||
// 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(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X"
|
||||
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()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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))
|
||||
|
||||
// ── Amount field — unchanged ──────────────────────────────────────
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
||||
label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
|
||||
label = { Text(strings.receive.amountInSats) },
|
||||
isError = amount.isNotBlank() && !isAmountValid,
|
||||
supportingText = {
|
||||
when {
|
||||
// Error: typed something out of range
|
||||
amount.isNotBlank() && !isAmountValid -> {
|
||||
Text(
|
||||
text = strings.lnurlAmountError( // ← "Enter an amount between X and Y sats"
|
||||
state.minSats,
|
||||
state.maxSats
|
||||
),
|
||||
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
// Fiat range hint when rate is available
|
||||
fiatRate != null && fiatCurrency != null -> {
|
||||
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
||||
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
||||
if (isFixed)
|
||||
Text(strings.lnurlFiatRange(minFiat, maxFiat)) // ← "X – Y EUR" (fixed amount)
|
||||
else
|
||||
Text( // ← "X – Y sats · A – B EUR"
|
||||
strings.lnurlSatsAndFiatRange(
|
||||
state.minSats, state.maxSats,
|
||||
minFiat, maxFiat
|
||||
)
|
||||
)
|
||||
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
||||
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
||||
}
|
||||
// Sats-only range when no fiat rate
|
||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X – Y sats"
|
||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
@@ -97,17 +139,22 @@ internal fun LnurlPayForm(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
// ── Comment field — unchanged ─────────────────────────────────────
|
||||
if (state.commentAllowed > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = comment,
|
||||
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
||||
label = { Text(strings.commentOptional(state.commentAllowed)) }, // ← "Comment (optional, max X chars)"
|
||||
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) }, // ← "X/Y"
|
||||
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
||||
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// ── Pay button — unchanged ────────────────────────────────────────
|
||||
Button(
|
||||
onClick = {
|
||||
if (sats != null && isAmountValid) {
|
||||
@@ -125,9 +172,23 @@ internal fun LnurlPayForm(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) // ← "Pay X sats · Y EUR"
|
||||
else strings.lnurlPayButton(amount) // ← "Pay X sats"
|
||||
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
|
||||
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