Compare commits

4 Commits

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