From f931405c9026141d0c1ff753acb1092c2bce821f Mon Sep 17 00:00:00 2001 From: rasputin Date: Sat, 13 Jun 2026 14:00:59 +0200 Subject: [PATCH] feat: add contacts 1 --- .../gudariwallet/data/ContactRepository.kt | 140 +++++ .../gudariwallet/data/db/AppDatabase.kt | 79 ++- .../gudariwallet/data/db/ContactDao.kt | 101 ++++ .../gudariwallet/data/db/ContactEntity.kt | 36 ++ .../data/db/PaymentAddressEntity.kt | 42 ++ .../gudariwallet/data/db/TxContactCrossRef.kt | 34 ++ .../bitcointxoko/gudariwallet/ui/HomeTab.kt | 26 +- .../gudariwallet/ui/WalletScreen.kt | 52 ++ .../gudariwallet/ui/WalletViewModel.kt | 31 ++ .../gudariwallet/ui/WalletViewModelFactory.kt | 3 + .../ui/contacts/ContactDetailScreen.kt | 511 ++++++++++++++++++ .../ui/contacts/ContactDetailViewModel.kt | 111 ++++ .../ui/contacts/ContactPickerSheet.kt | 151 ++++++ .../ui/contacts/ContactsScreen.kt | 196 +++++++ .../ui/contacts/ContactsViewModel.kt | 58 ++ .../ui/contacts/CreateContactSheet.kt | 160 ++++++ .../ui/history/HistoryViewModel.kt | 57 ++ .../ui/history/PaymentDetailScreen.kt | 280 +++++++--- .../gudariwallet/ui/send/LnurlPayForm.kt | 145 +++-- 19 files changed, 2090 insertions(+), 123 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactEntity.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentAddressEntity.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactPickerSheet.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/CreateContactSheet.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt new file mode 100644 index 0000000..71652c5 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -0,0 +1,140 @@ +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> = + dao.observeAllWithAddresses() + + fun observeContact(id: String): Flow = + 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> = + 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> = + dao.observePaymentIdsForContact(contactId) + +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt index e952d60..ec7da92 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt new file mode 100644 index 0000000..7d886d2 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt @@ -0,0 +1,101 @@ +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 +) + +@Dao +interface ContactDao { + + // --- Contacts --- + + @Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC") + fun observeAll(): Flow> + + @Transaction + @Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC") + fun observeAllWithAddresses(): Flow> + + @Transaction + @Query("SELECT * FROM contacts WHERE id = :id") + fun observeById(id: String): Flow + + @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> + + // 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> + + // 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> +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactEntity.kt new file mode 100644 index 0000000..1d03652 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactEntity.kt @@ -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() +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentAddressEntity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentAddressEntity.kt new file mode 100644 index 0000000..b3fbd58 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/PaymentAddressEntity.kt @@ -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 +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt new file mode 100644 index 0000000..fa06def --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/TxContactCrossRef.kt @@ -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() +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt index 01a40f6..64373f8 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt @@ -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 // ← NEW: passed in from wherever you call ProvideStrings ) { val strings = LocalAppStrings.current @@ -284,19 +286,25 @@ fun HomeTab( } } } - // ── NWC button — top-right corner ───────────────────────────────── - IconButton( - onClick = onNwcClick, + Row( modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) ) { - Icon( - imageVector = Icons.Filled.Cable, - contentDescription = "Wallet Connect", - tint = MaterialTheme.colorScheme.onSurfaceVariant - .copy(alpha = 0.7f) - ) + 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) + ) + } } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index d6a4c2b..737a539 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -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 ──── diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index dd4e006..06554cf 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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 = + 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() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index bd1ac46..450f0d5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt new file mode 100644 index 0000000..ba7d41a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt @@ -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") } + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt new file mode 100644 index 0000000..7b7fc0b --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt @@ -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 = 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 = _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 = _showAddAddressSheet.asStateFlow() + + fun openAddAddressSheet() { _showAddAddressSheet.value = true } + fun closeAddAddressSheet() { _showAddAddressSheet.value = false } + + // ── Edit alias sheet state ───────────────────────────────────────────── + + private val _showEditAliasSheet = MutableStateFlow(false) + val showEditAliasSheet: StateFlow = _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 create(modelClass: Class): T = + ContactDetailViewModel(contactId, repo) as T + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactPickerSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactPickerSheet.kt new file mode 100644 index 0000000..92373a1 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactPickerSheet.kt @@ -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, + 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") + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsScreen.kt new file mode 100644 index 0000000..cacae0e --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsScreen.kt @@ -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 + ) + } + } + } + ) +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt new file mode 100644 index 0000000..a7dc0fa --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt @@ -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) : ContactsUiState() +} + +class ContactsViewModel( + private val repo: ContactRepository +) : ViewModel() { + + val uiState: StateFlow = 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 create(modelClass: Class): T = + ContactsViewModel(repo) as T + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/CreateContactSheet.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/CreateContactSheet.kt new file mode 100644 index 0000000..8e70d0a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/CreateContactSheet.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt index 210437a..f778399 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/HistoryViewModel.kt @@ -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, val fiatSatsPerUnit : StateFlow ) : ViewModel() { @@ -151,6 +156,56 @@ class HistoryViewModel( else list.filter { it.extra?.tag in f.types } } + + + // ── Contact assignment ─────────────────────────────────────────────────── + + fun contactsForPayment(paymentHash: String): StateFlow> = + contactRepo.observeContactsForPayment(paymentHash) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + fun allContacts(): StateFlow> = + 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) + } + } + // ── Init ────────────────────────────────────────────────────────────────── init { syncManager.init() @@ -186,6 +241,7 @@ class HistoryViewModelFactory( private val repo : WalletRepository, private val aliasRepo : NodeAliasRepository, private val paymentCache : PaymentCacheRepository, + private val contactRepo : ContactRepository, private val fiatCurrency : StateFlow, private val fiatSatsPerUnit: StateFlow ) : ViewModelProvider.Factory { @@ -195,6 +251,7 @@ class HistoryViewModelFactory( repo = repo, aliasRepo = aliasRepo, paymentCache = paymentCache, + contactRepo = contactRepo, fiatCurrency = fiatCurrency, fiatSatsPerUnit = fiatSatsPerUnit ) as T diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt index 7c01f65..696a8ae 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/history/PaymentDetailScreen.kt @@ -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 @@ -63,8 +75,8 @@ fun PaymentDetailScreen( vm : HistoryViewModel, onBack : () -> Unit ) { - val strings = LocalAppStrings.current - val detailState by vm.detailState.collectAsState() + val strings = LocalAppStrings.current + val detailState by vm.detailState.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() @@ -76,14 +88,31 @@ fun PaymentDetailScreen( val detail = s.detail.details when { detail == null -> payment - else -> detail.copy( - memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, + else -> detail.copy( + memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11 ) } } is DetailState.Partial -> s.record - else -> payment + else -> payment + } + + // ── Contact state ───────────────────────────────────────────────────────── + val linkedContacts by remember(payment.checkingId) { + vm.contactsForPayment(payment.checkingId) + }.collectAsStateWithLifecycle() + + val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle() + + var showContactPicker by remember { mutableStateOf(false) } + var showCreateContact by remember { mutableStateOf(false) } + + // Best-effort lnAddress to pre-fill CreateContactSheet: + // prefer extra.comment if it looks like a Lightning Address, else null + val paymentLnAddress = remember(enriched) { + enriched.extra?.comment + ?.takeIf { it.contains("@") && !it.contains(" ") } } Scaffold( @@ -91,15 +120,15 @@ fun PaymentDetailScreen( 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 ) } } @@ -109,8 +138,8 @@ fun PaymentDetailScreen( when (detailState) { is DetailState.Loading -> { Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } @@ -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,33 +159,78 @@ fun PaymentDetailScreen( PaymentDetailContent( payment = enriched, fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit + fiatSatsPerUnit = fiatSatsPerUnit, + linkedContacts = linkedContacts, + onAssignContact = { showContactPicker = true }, + onUnassignContact = { contactId -> + vm.unassignContact(payment.checkingId, contactId) + } ) } } else -> { PaymentDetailContent( - payment = enriched, - fiatCurrency = fiatCurrency, - fiatSatsPerUnit = fiatSatsPerUnit, - modifier = Modifier.padding(padding) + 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?, - modifier : Modifier = Modifier + payment : PaymentRecord, + fiatCurrency : String?, + fiatSatsPerUnit : Double?, + linkedContacts : List, + onAssignContact : () -> Unit, + onUnassignContact : (contactId: String) -> Unit, + modifier : Modifier = Modifier ) { - val strings = LocalAppStrings.current + val strings = LocalAppStrings.current val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val context = LocalContext.current @@ -178,45 +252,42 @@ private fun PaymentDetailContent( } LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // ── Hero amount ────────────────────────────────────────────────────── + // ── Hero amount ──────────────────────────────────────────────────────── item { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Icon( imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward - else Icons.Default.ArrowDownward, + else Icons.Default.ArrowDownward, contentDescription = null, tint = amountColor, modifier = Modifier.size(36.dp) ) Spacer(Modifier.height(8.dp)) AmountWithFiatColumn( - amountSat = payment.amountSat, - isOutgoing = payment.isOutgoing, - amountColor = amountColor, - fiatLine = heroFiat, - style = MaterialTheme.typography.headlineLarge, + amountSat = payment.amountSat, + isOutgoing = payment.isOutgoing, + amountColor = amountColor, + fiatLine = heroFiat, + style = MaterialTheme.typography.headlineLarge, horizontalAlignment = Alignment.CenterHorizontally ) if (payment.feeSat > 0) { - val ppm = feePpm( + val ppm = feePpm( 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 ppmPart = if (ppm != null) " · $ppm ppm" else "" + val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart) Spacer(Modifier.height(4.dp)) Text( text = feeLabel, @@ -230,47 +301,118 @@ 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( + 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.detailRowMemo, // ← "Memo" - payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" + 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" - value = payment.paymentHash, + label = strings.history.detailRowPaymentHash, + value = payment.paymentHash, monospace = true, copyable = true, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash)) @@ -280,11 +422,11 @@ private fun PaymentDetailContent( ) if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { DetailRow( - label = strings.history.detailRowPreimage, // ← "Preimage" - value = payment.preimage, + label = strings.history.detailRowPreimage, + value = payment.preimage, monospace = true, copyable = true, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("Preimage", payment.preimage)) @@ -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,12 +453,12 @@ private fun PaymentDetailContent( } if (!payment.bolt11.isNullOrBlank()) { DetailRow( - label = strings.history.detailRowBolt11, // ← "BOLT11" - value = payment.bolt11, + label = strings.history.detailRowBolt11, + value = payment.bolt11, monospace = true, copyable = true, maxLines = 3, - onCopy = { + onCopy = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11)) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt index a3fe8d3..7425853 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt @@ -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,84 +51,120 @@ 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 { - Text( - text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X" - style = MaterialTheme.typography.titleMedium - ) + // ── 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(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, + value = amount, onValueChange = { if (it.all(Char::isDigit)) amount = it }, - label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)" - isError = amount.isNotBlank() && !isAmountValid, + 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" - else -> {} + !isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) + else -> {} } }, - enabled = !isFixed, - singleLine = true, - modifier = Modifier.fillMaxWidth(), + enabled = !isFixed, + singleLine = true, + modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) ) + + // ── Comment field — unchanged ───────────────────────────────────── if (state.commentAllowed > 0) { Spacer(Modifier.height(8.dp)) OutlinedTextField( - value = comment, + 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" - modifier = Modifier.fillMaxWidth() + 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) { vm.fetchLnurlInvoice( - rawRes = state.rawRes, - lnurl = state.lnurl, - domain = state.domain, + rawRes = state.rawRes, + lnurl = state.lnurl, + domain = state.domain, amountMsat = sats * WalletConstants.MSAT_PER_SAT, - comment = comment.takeIf { it.isNotBlank() }, - strings = sendStrings + comment = comment.takeIf { it.isNotBlank() }, + strings = sendStrings ) } }, @@ -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 } + ) + } } +