feat: add contacts 1

This commit is contained in:
2026-06-13 14:00:59 +02:00
parent c3490808c0
commit f931405c90
19 changed files with 2090 additions and 123 deletions
@@ -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<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)
}
@@ -14,9 +14,12 @@ import androidx.sqlite.db.SupportSQLiteDatabase
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
NwcKeyEntity::class, NwcKeyEntity::class,
NwcBudgetEntity::class NwcBudgetEntity::class,
ContactEntity::class,
PaymentAddressEntity::class,
TxContactCrossRef::class,
], ],
version = 7, version = 9,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -26,6 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao abstract fun nwcKeyDao(): NwcKeyDao
abstract fun contactDao(): ContactDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
@@ -124,6 +128,73 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS contacts (
id TEXT NOT NULL PRIMARY KEY,
localAlias TEXT,
displayName TEXT,
name TEXT,
pictureUrl TEXT,
nostrPubkey TEXT,
nip05 TEXT,
about TEXT,
lnAddress TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
ON contacts(nostrPubkey)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
ON contacts(lnAddress)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS payment_addresses (
id TEXT NOT NULL PRIMARY KEY,
contactId TEXT NOT NULL,
type TEXT NOT NULL,
address TEXT NOT NULL,
label TEXT,
isDefault INTEGER NOT NULL DEFAULT 0,
addedAt INTEGER NOT NULL,
FOREIGN KEY(contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
ON payment_addresses(contactId)
""".trimIndent())
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
createdAt INTEGER NOT NULL,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -137,7 +208,9 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_3_4, MIGRATION_3_4,
MIGRATION_4_5, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_5_6,
MIGRATION_6_7 MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9
) )
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
@@ -0,0 +1,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<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>>
}
@@ -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,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()
)
@@ -26,6 +26,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.People
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -40,6 +41,7 @@ fun HomeTab(
vm: WalletViewModel, vm: WalletViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onNwcClick : () -> Unit, onNwcClick : () -> Unit,
onContactsClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -284,22 +286,28 @@ fun HomeTab(
} }
} }
} }
// ── NWC button — top-right corner ───────────────────────────────── Row(
IconButton(
onClick = onNwcClick,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(8.dp)
) { ) {
IconButton(onClick = onContactsClick) {
Icon(
imageVector = Icons.Filled.People,
contentDescription = "Contacts",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
IconButton(onClick = onNwcClick) {
Icon( Icon(
imageVector = Icons.Filled.Cable, imageVector = Icons.Filled.Cable,
contentDescription = "Wallet Connect", contentDescription = "Wallet Connect",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
.copy(alpha = 0.7f)
) )
} }
} }
} }
}
// ── NEW: Language switcher composable ───────────────────────────────────────── // ── NEW: Language switcher composable ─────────────────────────────────────────
@Composable @Composable
@@ -57,6 +57,8 @@ import cafe.adriel.lyricist.Lyricist
import com.bitcointxoko.gudariwallet.MainActivity import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
@@ -70,6 +72,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -88,6 +93,9 @@ private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_NWC = "nwc" private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}" private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
private const val ROUTE_CONTACTS = "contacts"
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
@Composable @Composable
fun WalletScreen( fun WalletScreen(
@@ -122,6 +130,7 @@ fun WalletScreen(
repo = vm.repo, repo = vm.repo,
aliasRepo = vm.aliasRepo, aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache, paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
fiatCurrency = vm.selectedCurrency, fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit fiatSatsPerUnit = vm.fiatSatsPerUnit
) )
@@ -130,6 +139,9 @@ fun WalletScreen(
val nwcVm: NwcViewModel = viewModel( val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
) )
val contactsVm: ContactsViewModel = viewModel(
factory = ContactsViewModel.Factory(vm.contactRepo)
)
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -139,12 +151,16 @@ fun WalletScreen(
&& currentRoute != ROUTE_HISTORY && currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC && currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL && currentRoute != ROUTE_CONNECTION_DETAIL
&& currentRoute != ROUTE_CONTACTS
&& currentRoute != ROUTE_CONTACT_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER || currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC || currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL || currentRoute == ROUTE_CONNECTION_DETAIL
|| currentRoute == ROUTE_CONTACTS
|| currentRoute == ROUTE_CONTACT_DETAIL
// ── Notification tap ────────────────────────────────────────────────────── // ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value val hash = pendingPaymentHash.value
@@ -341,6 +357,9 @@ fun WalletScreen(
launchSingleTop = true launchSingleTop = true
} }
}, },
onContactsClick = {
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
},
lyricist = lyricist lyricist = lyricist
) )
} }
@@ -498,6 +517,39 @@ fun WalletScreen(
} }
} }
} }
composable(
ROUTE_CONTACTS,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
ContactsScreen(
viewModel = contactsVm,
onContactClick = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
}
)
}
composable(
route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel(
key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
)
ContactDetailScreen(
viewModel = detailVm,
onBack = { navController.popBackStack() }
)
}
} }
// ── Clipboard banner — floats over content, slides in from top ──── // ── Clipboard banner — floats over content, slides in from top ────
@@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -21,6 +24,7 @@ import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -30,6 +34,7 @@ class WalletViewModel(
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository, val nwcCache : NwcCacheRepository,
val contactRepo : ContactRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore, private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
@@ -126,6 +131,32 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text) fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer() fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
sendVm.sendState
.map { state ->
val lnurl = (state as? SendState.LnurlReady)?.lnurl
?: return@map null
contactRepo.findByLnAddress(lnurl)
?: contactRepo.findByLnurl(lnurl)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
}
}
init { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
@@ -12,6 +12,7 @@ import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
@@ -37,6 +38,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val poller = PaymentPoller(repo) val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app) val nwcCache = NwcCacheRepository(app)
val contactRepo = ContactRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app) val lnAddressStore = LightningAddressStore(app)
@@ -65,6 +67,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
nwcCache = nwcCache, nwcCache = nwcCache,
contactRepo = contactRepo,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
fiatVm = fiatVm, fiatVm = fiatVm,
@@ -0,0 +1,511 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailScreen(
viewModel: ContactDetailViewModel,
onBack: () -> Unit
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
val title = when (val s = uiState) {
is ContactDetailUiState.Success ->
s.data.contact.localAlias
?: s.data.contact.displayName
?: s.data.contact.name
?: "Contact"
else -> "Contact"
}
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
},
actions = {
if (uiState is ContactDetailUiState.Success) {
IconButton(onClick = { viewModel.openEditAliasSheet() }) {
Icon(Icons.Default.Edit, contentDescription = "Edit name")
}
IconButton(onClick = { viewModel.requestDelete() }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete contact",
tint = MaterialTheme.colorScheme.error
)
}
}
}
)
},
floatingActionButton = {
if (uiState is ContactDetailUiState.Success) {
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add payment address")
}
}
}
) { innerPadding ->
when (val state = uiState) {
is ContactDetailUiState.Loading -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ContactDetailUiState.NotFound -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
"Contact not found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
is ContactDetailUiState.Success -> {
ContactDetailContent(
state = state,
onRemoveAddress = { viewModel.removeAddress(it) },
onSetDefault = { viewModel.setDefaultAddress(it) },
modifier = Modifier.padding(innerPadding)
)
}
}
}
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
if (pendingDelete) {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Delete contact") },
text = { Text("This contact and all their addresses will be permanently removed.") },
confirmButton = {
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) {
Text(strings.cancel)
}
}
)
}
// ── Add address sheet ─────────────────────────────────────────────────
if (showAddAddressSheet) {
AddAddressSheet(
onSave = { type, address, label, makeDefault ->
viewModel.addAddress(type, address, label, makeDefault)
viewModel.closeAddAddressSheet()
},
onDismiss = { viewModel.closeAddAddressSheet() }
)
}
// ── Edit alias sheet ──────────────────────────────────────────────────
if (showEditAliasSheet) {
val currentAlias = (uiState as? ContactDetailUiState.Success)
?.data?.contact?.localAlias ?: ""
EditAliasSheet(
currentAlias = currentAlias,
onSave = { newAlias ->
viewModel.updateAlias(newAlias)
viewModel.closeEditAliasSheet()
},
onDismiss = { viewModel.closeEditAliasSheet() }
)
}
}
// ── Main content ───────────────────────────────────────────────────────────────
@Composable
private fun ContactDetailContent(
state: ContactDetailUiState.Success,
onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit,
modifier: Modifier = Modifier
) {
val contact = state.data.contact
val addresses = state.data.addresses
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
) {
// ── Avatar + name hero ─────────────────────────────────────────────
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "?"
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(72.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.height(12.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge
)
contact.nip05?.let { nip05 ->
Text(
text = nip05,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ── Payment addresses section ─────────────────────────────────────
item {
DetailSection(title = "Payment addresses") {}
}
if (addresses.isEmpty()) {
item {
Text(
text = "No addresses added yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
} else {
items(addresses, key = { it.id }) { addr ->
AddressRow(
address = addr,
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
contact.about?.let { about ->
item {
DetailSection(title = "About") {
DetailRow(label = "Bio", value = about, maxLines = 4)
}
}
}
}
}
// ── Address row ────────────────────────────────────────────────────────────────
@Composable
private fun AddressRow(
address: PaymentAddressEntity,
onRemove: () -> Unit,
onSetDefault: () -> Unit
) {
val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
PaymentAddressType.LNURL.name -> "LNURL"
PaymentAddressType.ON_CHAIN.name -> "On-chain"
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
PaymentAddressType.BIP353.name -> "BIP-353"
else -> address.type
}
val label = address.label?.let { "$typeLabel · $it" } ?: typeLabel
DetailRow(
label = label,
value = address.address,
maxLines = 1,
trailingContent = {
Row {
// Set default star — filled if already default
IconButton(
onClick = onSetDefault,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = if (address.isDefault) Icons.Filled.Star
else Icons.Outlined.Star,
contentDescription = if (address.isDefault) "Default address"
else "Set as default",
modifier = Modifier.size(16.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Remove
IconButton(
onClick = onRemove,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove address",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
)
}
// ── Add address sheet ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddAddressSheet(
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val addressFocus = remember { FocusRequester() }
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf("") }
var label by remember { mutableStateOf("") }
var makeDefault by remember { mutableStateOf(false) }
val canSave = address.isNotBlank()
LaunchedEffect(Unit) { addressFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
// Type chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
label = { Text("LNURL") }
)
}
// Address field
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"Lightning Address" else "LNURL"
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"user@domain.com" else "LNURL1..."
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(addressFocus)
)
// Optional label
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
placeholder = { Text("e.g. personal, savings") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
}
),
modifier = Modifier.fillMaxWidth()
)
// Set as default toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Set as default",
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = makeDefault,
onCheckedChange = { makeDefault = it }
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = {
focusManager.clearFocus()
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
},
enabled = canSave
) { Text("Save") }
}
}
}
}
// ── Edit alias sheet ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditAliasSheet(
currentAlias: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var alias by remember { mutableStateOf(currentAlias) }
val canSave = alias.isNotBlank()
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Edit name", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = alias,
onValueChange = { alias = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(alias)
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = { focusManager.clearFocus(); onSave(alias) },
enabled = canSave
) { Text("Save") }
}
}
}
}
@@ -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")
}
}
}
}
}
@@ -5,9 +5,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
@@ -30,6 +34,7 @@ class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
val fiatCurrency : StateFlow<String?>, val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?> val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModel() { ) : ViewModel() {
@@ -151,6 +156,56 @@ class HistoryViewModel(
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
// ── 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)
}
}
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
init { init {
syncManager.init() syncManager.init()
@@ -186,6 +241,7 @@ class HistoryViewModelFactory(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val fiatCurrency : StateFlow<String?>, private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit: StateFlow<Double?> private val fiatSatsPerUnit: StateFlow<Double?>
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -195,6 +251,7 @@ class HistoryViewModelFactory(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
contactRepo = contactRepo,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit
) as T ) as T
@@ -6,23 +6,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,8 +38,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
@@ -41,20 +49,24 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.ui.common.StatusChip import com.bitcointxoko.gudariwallet.ui.common.StatusChip
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Detail screen ──────────────────────────────────────────────────────────── // ── Detail screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -86,20 +98,37 @@ fun PaymentDetailScreen(
else -> payment else -> payment
} }
// ── Contact state ─────────────────────────────────────────────────────────
val linkedContacts by remember(payment.checkingId) {
vm.contactsForPayment(payment.checkingId)
}.collectAsStateWithLifecycle()
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
var showContactPicker by remember { mutableStateOf(false) }
var showCreateContact by remember { mutableStateOf(false) }
// Best-effort lnAddress to pre-fill CreateContactSheet:
// prefer extra.comment if it looks like a Lightning Address, else null
val paymentLnAddress = remember(enriched) {
enriched.extra?.comment
?.takeIf { it.contains("@") && !it.contains(" ") }
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
if (payment.isOutgoing) strings.history.detailOutgoingTitle // ← "Outgoing payment" if (payment.isOutgoing) strings.history.detailOutgoingTitle
else strings.history.detailIncomingTitle // ← "Incoming payment" else strings.history.detailIncomingTitle
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.history.detailBack // ← "Back" contentDescription = strings.history.detailBack
) )
} }
} }
@@ -121,7 +150,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = strings.history.detailCouldNotLoad, // ← "Could not load full details" text = strings.history.detailCouldNotLoad,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
@@ -130,7 +159,12 @@ fun PaymentDetailScreen(
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(payment.checkingId, contactId)
}
) )
} }
} }
@@ -140,20 +174,60 @@ fun PaymentDetailScreen(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(payment.checkingId, contactId)
},
modifier = Modifier.padding(padding) 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 }
)
} }
// ── Detail content ─────────────────────────────────────────────────────────── // ── 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 ─────────────────────────────────────────────────────────────
@Composable @Composable
private fun PaymentDetailContent( private fun PaymentDetailContent(
payment : PaymentRecord, payment : PaymentRecord,
fiatCurrency : String?, fiatCurrency : String?,
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -183,7 +257,7 @@ private fun PaymentDetailContent(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ── Hero amount ────────────────────────────────────────────────────── // ── Hero amount ────────────────────────────────────────────────────────
item { item {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -211,12 +285,9 @@ private fun PaymentDetailContent(
amountMsat = kotlin.math.abs(payment.amountMsat), amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat) feeMsat = kotlin.math.abs(payment.feeMsat)
) )
// Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else "" val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else "" val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.history.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm" val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
payment.feeSat, fiatPart, ppmPart
)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = feeLabel, text = feeLabel,
@@ -230,43 +301,114 @@ private fun PaymentDetailContent(
} }
} }
// ── Basic info ─────────────────────────────────────────────────────── // ── Contact section ────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionDetails) { // ← "Details" DetailSection(title = "Contact") {
DetailRow(strings.history.detailRowDate, formatTimestamp( // ← "Date" if (linkedContacts.isEmpty()) {
payment.createdAt, // No contact linked — show assign button
payment.time, OutlinedButton(
strings.today, onClick = onAssignContact,
strings.yesterday)) modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Assign contact")
}
} else {
// Show each linked contact with a remove button
linkedContacts.forEach { contact ->
val name = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
DetailRow( DetailRow(
strings.history.detailRowMemo, // ← "Memo" label = "Contact",
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty // ← "—" 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) { if (payment.extra?.tag != null) {
DetailRow(strings.history.detailRowType, payment.extra.tag) // ← "Type" DetailRow(strings.history.detailRowType, payment.extra.tag)
} }
if (payment.extra?.comment?.isNotBlank() == true) { if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow(strings.history.detailRowComment, payment.extra.comment) // ← "Comment" DetailRow(strings.history.detailRowComment, payment.extra.comment)
} }
} }
} }
// ── Success action (LNURL) ─────────────────────────────────────────── // ── Success action (LNURL) ─────────────────────────────────────────────
payment.extra?.successAction?.let { action -> payment.extra?.successAction?.let { action ->
item { item {
DetailSection(title = strings.history.detailSectionSuccessAction) { // ← "Success Action" DetailSection(title = strings.history.detailSectionSuccessAction) {
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) } // ← "Message" action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) } // ← "URL" action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) } // ← "Description" action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
} }
} }
} }
// ── Technical ──────────────────────────────────────────────────────── // ── Technical ────────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.history.detailSectionTechnical) { // ← "Technical" DetailSection(title = strings.history.detailSectionTechnical) {
DetailRow( DetailRow(
label = strings.history.detailRowPaymentHash, // ← "Payment Hash" label = strings.history.detailRowPaymentHash,
value = payment.paymentHash, value = payment.paymentHash,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -280,7 +422,7 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = strings.history.detailRowPreimage, // ← "Preimage" label = strings.history.detailRowPreimage,
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -297,7 +439,7 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = strings.history.detailRowDestination, // ← "Destination" label = strings.history.detailRowDestination,
value = label, value = label,
valueColor = MaterialTheme.colorScheme.primary, valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
@@ -311,7 +453,7 @@ private fun PaymentDetailContent(
} }
if (!payment.bolt11.isNullOrBlank()) { if (!payment.bolt11.isNullOrBlank()) {
DetailRow( DetailRow(
label = strings.history.detailRowBolt11, // ← "BOLT11" label = strings.history.detailRowBolt11,
value = payment.bolt11, value = payment.bolt11,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -4,10 +4,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -17,6 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.SendState import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
@@ -25,6 +32,10 @@ import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
import com.bitcointxoko.gudariwallet.util.payingToLabel import com.bitcointxoko.gudariwallet.util.payingToLabel
import com.bitcointxoko.gudariwallet.util.satsToFiat import com.bitcointxoko.gudariwallet.util.satsToFiat
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
@Composable @Composable
internal fun LnurlPayForm( internal fun LnurlPayForm(
@@ -40,55 +51,86 @@ internal fun LnurlPayForm(
var comment by remember { mutableStateOf("") } var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats val isFixed = state.minSats == state.maxSats
// Long? — null means the field is empty or non-numeric, not zero.
val sats: Long? = amount.toLongOrNull() val sats: Long? = amount.toLongOrNull()
val isAmountValid = sats != null && sats in state.minSats..state.maxSats val isAmountValid = sats != null && sats in state.minSats..state.maxSats
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency) val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
var showSaveContact by remember { mutableStateOf(false) }
// ── Derive address type directly from the lnurl string ───────────────
val addressType = remember(state.lnurl) {
when (SendInputDetector.detect(state.lnurl)) {
SendInputType.LightningAddress -> PaymentAddressType.LIGHTNING_ADDRESS
else -> PaymentAddressType.LNURL
}
}
Column { Column {
// ── 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(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X" text = strings.payingTo(contactName),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
} else {
// Unknown — show domain/address as before
Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
style = MaterialTheme.typography.titleMedium
)
}
if (state.description.isNotBlank()) { if (state.description.isNotBlank()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text(state.description, style = MaterialTheme.typography.bodySmall) Text(state.description, style = MaterialTheme.typography.bodySmall)
} }
// ── "Add to contacts" chip — only when no existing contact ───────
if (existingContact == null) {
Spacer(Modifier.height(8.dp))
SuggestionChip(
onClick = { showSaveContact = true },
label = { Text("Add to contacts") },
icon = {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(SuggestionChipDefaults.IconSize)
)
}
)
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
// ── Amount field — unchanged ──────────────────────────────────────
OutlinedTextField( OutlinedTextField(
value = amount, value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it }, onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)" label = { Text(strings.receive.amountInSats) },
isError = amount.isNotBlank() && !isAmountValid, isError = amount.isNotBlank() && !isAmountValid,
supportingText = { supportingText = {
when { when {
// Error: typed something out of range
amount.isNotBlank() && !isAmountValid -> { amount.isNotBlank() && !isAmountValid -> {
Text( Text(
text = strings.lnurlAmountError( // ← "Enter an amount between X and Y sats" text = strings.lnurlAmountError(state.minSats, state.maxSats),
state.minSats,
state.maxSats
),
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
// Fiat range hint when rate is available
fiatRate != null && fiatCurrency != null -> { fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency) val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency) val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
if (isFixed) if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
Text(strings.lnurlFiatRange(minFiat, maxFiat)) // ← "X Y EUR" (fixed amount) else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
else
Text( // ← "X Y sats · A B EUR"
strings.lnurlSatsAndFiatRange(
state.minSats, state.maxSats,
minFiat, maxFiat
)
)
} }
// Sats-only range when no fiat rate !isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
else -> {} else -> {}
} }
}, },
@@ -97,17 +139,22 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
) )
// ── Comment field — unchanged ─────────────────────────────────────
if (state.commentAllowed > 0) { if (state.commentAllowed > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = comment, value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it }, onValueChange = { if (it.length <= state.commentAllowed) comment = it },
label = { Text(strings.commentOptional(state.commentAllowed)) }, // ← "Comment (optional, max X chars)" label = { Text(strings.commentOptional(state.commentAllowed)) },
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) }, // ← "X/Y" supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// ── Pay button — unchanged ────────────────────────────────────────
Button( Button(
onClick = { onClick = {
if (sats != null && isAmountValid) { if (sats != null && isAmountValid) {
@@ -125,9 +172,23 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) // ← "Pay X sats · Y EUR" if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
else strings.lnurlPayButton(amount) // ← "Pay X sats" else strings.lnurlPayButton(amount)
) )
} }
} }
// ── CreateContactSheet — pre-filled with resolved address ─────────────
if (showSaveContact) {
CreateContactSheet(
initialAddressType = addressType,
initialAddress = state.lnurl,
onSave = { name, type, address ->
vm.saveContactFromSend(name, type, address)
showSaveContact = false
},
onDismiss = { showSaveContact = false }
)
} }
}