Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0badcdf5 | |||
| 3c5539d2cb | |||
| 19970659b8 | |||
| c6885f6307 | |||
| d5c461c2e6 | |||
| 985c80373d | |||
| bcea6451e7 | |||
| 53cb600408 | |||
| effe8f4b04 | |||
| c817768e8a | |||
| 0e7469053d | |||
| ebeac511b7 | |||
| 2e11cecbeb | |||
| e57414de17 | |||
| 0aa55710a9 | |||
| ef03a986a4 |
@@ -22,5 +22,21 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"elementType": "File",
|
"elementType": "File",
|
||||||
|
"baselineProfiles": [
|
||||||
|
{
|
||||||
|
"minApi": 28,
|
||||||
|
"maxApi": 30,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/1/app-arm64-v8a-release.dm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minApi": 31,
|
||||||
|
"maxApi": 2147483647,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/0/app-arm64-v8a-release.dm"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"minSdkVersionForDexing": 26
|
"minSdkVersionForDexing": 26
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import com.bitcointxoko.gudariwallet.data.db.*
|
import com.bitcointxoko.gudariwallet.data.db.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class ContactRepository(app: Application) {
|
class ContactRepository(app: Application) {
|
||||||
private val dao = AppDatabase.getInstance(app).contactDao()
|
private val dao = AppDatabase.getInstance(app).contactDao()
|
||||||
@@ -16,6 +17,11 @@ class ContactRepository(app: Application) {
|
|||||||
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
||||||
dao.observeById(id)
|
dao.observeById(id)
|
||||||
|
|
||||||
|
/** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */
|
||||||
|
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>> =
|
||||||
|
dao.observePaymentContactSummaries()
|
||||||
|
|
||||||
|
|
||||||
// --- Create ---
|
// --- Create ---
|
||||||
|
|
||||||
suspend fun createContact(
|
suspend fun createContact(
|
||||||
@@ -103,6 +109,35 @@ class ContactRepository(app: Application) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
makeDefault: Boolean
|
||||||
|
) {
|
||||||
|
val existing = dao.getAddressById(addressId) ?: return
|
||||||
|
val contactId = existing.contactId
|
||||||
|
|
||||||
|
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.updateAddress(
|
||||||
|
addressId = addressId,
|
||||||
|
type = type.name,
|
||||||
|
address = address,
|
||||||
|
label = label,
|
||||||
|
isDefault = makeDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
suspend fun removeAddress(addressId: String) {
|
suspend fun removeAddress(addressId: String) {
|
||||||
dao.deleteAddressById(addressId)
|
dao.deleteAddressById(addressId)
|
||||||
}
|
}
|
||||||
@@ -115,6 +150,8 @@ class ContactRepository(app: Application) {
|
|||||||
|
|
||||||
// --- Lookup (for send flow) ---
|
// --- Lookup (for send flow) ---
|
||||||
|
|
||||||
|
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
|
||||||
|
|
||||||
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
|
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
|
||||||
dao.findByLnAddress(lnAddress)
|
dao.findByLnAddress(lnAddress)
|
||||||
|
|
||||||
@@ -123,15 +160,17 @@ class ContactRepository(app: Application) {
|
|||||||
|
|
||||||
// --- Contact-Tx link ---
|
// --- Contact-Tx link ---
|
||||||
|
|
||||||
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
|
||||||
dao.observeContactsForPayment(checkingId)
|
dao.observeContactsForPayment(paymentHash)
|
||||||
|
|
||||||
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
|
suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
|
||||||
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
|
Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
|
||||||
dao.unlinkPaymentFromContact(checkingId, contactId)
|
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.unlinkPaymentFromContact(paymentHash, contactId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
||||||
@@ -139,11 +178,23 @@ class ContactRepository(app: Application) {
|
|||||||
|
|
||||||
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
||||||
dao.observePaymentContactNames()
|
dao.observePaymentContactNames()
|
||||||
.map { list -> list.associate { it.checkingId to it.contactName } }
|
.map { list ->
|
||||||
|
list.associate { it.paymentHash to it.contactName }
|
||||||
|
.also { map ->
|
||||||
|
Timber.d(
|
||||||
|
"CONTACT [MAP EMIT ] ${map.size} entries: ${
|
||||||
|
map.entries.take(5).joinToString { "${it.key.take(8)}→${it.value}" }
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactName(query: String): List<String> =
|
suspend fun findPaymentHashesByContactName(query: String): List<String> =
|
||||||
dao.findCheckingIdsByContactName(query)
|
dao.findPaymentHashesByContactName(query)
|
||||||
|
|
||||||
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
|
||||||
dao.findCheckingIdsByContactIds(contactIds)
|
dao.findPaymentHashesByContactIds(contactIds)
|
||||||
|
|
||||||
|
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity> =
|
||||||
|
dao.getContactsForPayment(paymentHash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val TAG = "FiatRateCache"
|
private const val TAG = "FiatRateCache"
|
||||||
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
|
|||||||
*/
|
*/
|
||||||
class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||||
|
|
||||||
|
private val _fiatFlow = MutableStateFlow<Pair<String?, Double?>>(Pair(null, null))
|
||||||
|
|
||||||
|
/** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */
|
||||||
|
val fiatFlow: StateFlow<Pair<String?, Double?>> = _fiatFlow.asStateFlow()
|
||||||
|
|
||||||
|
|
||||||
// ── Rate ──────────────────────────────────────────────────────────────────
|
// ── Rate ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private var cachedRate : Double? = null
|
private var cachedRate : Double? = null
|
||||||
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
cachedRate = rate
|
cachedRate = rate
|
||||||
rateLastFetchedAt = now
|
rateLastFetchedAt = now
|
||||||
cachedRateCurrency = currency
|
cachedRateCurrency = currency
|
||||||
|
_fiatFlow.value = Pair(currency, rate)
|
||||||
fiatDataStore.persistRate(currency, rate)
|
fiatDataStore.persistRate(currency, rate)
|
||||||
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
|
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
|
||||||
}
|
}
|
||||||
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
cachedRate = null
|
cachedRate = null
|
||||||
rateLastFetchedAt = 0L
|
rateLastFetchedAt = 0L
|
||||||
cachedRateCurrency = ""
|
cachedRateCurrency = ""
|
||||||
|
_fiatFlow.value = Pair(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Currency list ─────────────────────────────────────────────────────────
|
// ── Currency list ─────────────────────────────────────────────────────────
|
||||||
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
|
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentRate: Double?
|
val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
||||||
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity
|
|||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
|
||||||
|
|
||||||
private const val TAG = "PaymentCacheRepository"
|
private const val TAG = "PaymentCacheRepository"
|
||||||
|
|
||||||
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
if (payments.isEmpty()) return
|
if (payments.isEmpty()) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
runCatching {
|
runCatching {
|
||||||
dao.insertAll(payments.map { it.toEntity(now) })
|
payments.forEach { payment ->
|
||||||
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
val e = payment.toEntity(now)
|
||||||
|
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Persist the first page of payments to the database. */
|
/** Persist the first page of payments to the database. */
|
||||||
suspend fun savePayments(payments: List<PaymentRecord>) {
|
suspend fun savePayments(payments: List<PaymentRecord>) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -114,6 +137,9 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */
|
||||||
|
fun observeAll(): Flow<List<PaymentRecordEntity>> = dao.observeAll()
|
||||||
|
|
||||||
// ── Payment detail ────────────────────────────────────────────────────────
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns a cached detail response, or null if not yet fetched this session. */
|
/** Returns a cached detail response, or null if not yet fetched this session. */
|
||||||
@@ -130,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
|
|
||||||
/** Upsert PaymentRecord to database. */
|
/** Upsert PaymentRecord to database. */
|
||||||
suspend fun upsertPayment(payment: PaymentRecord) {
|
suspend fun upsertPayment(payment: PaymentRecord) {
|
||||||
val entity = payment.toEntity(savedAt = System.currentTimeMillis())
|
val e = payment.toEntity(savedAt = System.currentTimeMillis())
|
||||||
dao.insert(entity)
|
Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +202,33 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
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> {
|
* Fetch payments by checkingId with the full PaymentFilter applied.
|
||||||
if (checkingIds.isEmpty()) return emptyList()
|
* Used by HistoryViewModel for contact name search supplement and
|
||||||
return dao.getByCheckingIds(checkingIds).map { it.toDomain() }
|
* contact ID filter. Amount/date/status are applied in SQL;
|
||||||
|
* direction and type are handled in-memory afterwards (same as queryAll).
|
||||||
|
*/
|
||||||
|
suspend fun queryByPaymentHashes(
|
||||||
|
paymentHashes : List<String>,
|
||||||
|
filter : PaymentFilter
|
||||||
|
): List<PaymentRecord> {
|
||||||
|
if (paymentHashes.isEmpty()) return emptyList()
|
||||||
|
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
|
||||||
|
filter.statuses.flatMap { s ->
|
||||||
|
when (s) {
|
||||||
|
StatusFilter.COMPLETED -> listOf("success", "complete", "paid")
|
||||||
|
StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight")
|
||||||
|
StatusFilter.FAILED -> listOf("failed", "error", "expired")
|
||||||
|
}
|
||||||
|
}.joinToString(",")
|
||||||
|
}
|
||||||
|
return dao.getByPaymentHashesFiltered(
|
||||||
|
paymentHashes = paymentHashes,
|
||||||
|
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
|
||||||
|
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
|
||||||
|
minCreatedAt = filter.minCreatedAt,
|
||||||
|
maxCreatedAt = filter.maxCreatedAt,
|
||||||
|
statusPattern = statusPattern
|
||||||
|
).map { it.toDomain() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
class PaymentFeedRepository(
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
|
private val nodeAliasRepository : NodeAliasRepository,
|
||||||
|
private val fiatVm : FiatViewModel
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeFeedItems(): Flow<List<PaymentFeedItem>> = combine(
|
||||||
|
paymentCache.observeAll(),
|
||||||
|
contactRepo.observePaymentContactSummaries(),
|
||||||
|
nodeAliasRepository.aliases,
|
||||||
|
fiatVm.fiatFlow
|
||||||
|
) { payments: List<PaymentRecordEntity>, contactSummaries, aliasMap, fiatRate ->
|
||||||
|
val fiatCurrency = fiatRate.first
|
||||||
|
val fiatSatsPerUnit = fiatRate.second
|
||||||
|
|
||||||
|
val contactsByPayment: Map<String, List<PaymentContactSummary>> =
|
||||||
|
contactSummaries.groupBy { it.paymentHash }
|
||||||
|
|
||||||
|
payments.map { entity ->
|
||||||
|
val linked: List<ContactSummary> = contactsByPayment[entity.paymentHash]
|
||||||
|
?.map { ContactSummary(id = it.contactId, displayName = it.displayName) }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
val resolvedAlias: String? = entity.pubkey?.let { aliasMap[it] }
|
||||||
|
|
||||||
|
entity.toPaymentFeedItem(
|
||||||
|
linkedContacts = linked,
|
||||||
|
resolvedAlias = resolvedAlias,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
PaymentAddressEntity::class,
|
PaymentAddressEntity::class,
|
||||||
TxContactCrossRef::class,
|
TxContactCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 9,
|
version = 10,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(PaymentConverters::class)
|
@TypeConverters(PaymentConverters::class)
|
||||||
@@ -195,6 +195,30 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// 1. Create replacement table — same schema minus the payment_records FK
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
// 2. Copy existing rows
|
||||||
|
database.execSQL("""
|
||||||
|
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
|
||||||
|
SELECT checkingId, contactId, role FROM tx_contact_links
|
||||||
|
""")
|
||||||
|
// 3. Swap tables
|
||||||
|
database.execSQL("DROP TABLE tx_contact_links")
|
||||||
|
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase =
|
fun getInstance(context: Context): AppDatabase =
|
||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -210,7 +234,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
MIGRATION_5_6,
|
MIGRATION_5_6,
|
||||||
MIGRATION_6_7,
|
MIGRATION_6_7,
|
||||||
MIGRATION_7_8,
|
MIGRATION_7_8,
|
||||||
MIGRATION_8_9
|
MIGRATION_8_9,
|
||||||
|
MIGRATION_9_10
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ data class ContactWithAddresses(
|
|||||||
val addresses: List<PaymentAddressEntity>
|
val addresses: List<PaymentAddressEntity>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Flat projection used by PaymentFeedRepository to build ContactSummary lists. */
|
||||||
|
data class PaymentContactSummary(
|
||||||
|
val paymentHash: String,
|
||||||
|
val contactId: String,
|
||||||
|
val displayName: String
|
||||||
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ContactDao {
|
interface ContactDao {
|
||||||
|
|
||||||
@@ -54,8 +61,24 @@ interface ContactDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertAddress(address: PaymentAddressEntity)
|
suspend fun insertAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
@Update
|
@Query("""
|
||||||
suspend fun updateAddress(address: PaymentAddressEntity)
|
UPDATE payment_addresses
|
||||||
|
SET type = :type,
|
||||||
|
address = :address,
|
||||||
|
label = :label,
|
||||||
|
isDefault = :isDefault
|
||||||
|
WHERE id = :addressId
|
||||||
|
""")
|
||||||
|
suspend fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: String,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
isDefault: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_addresses WHERE id = :addressId")
|
||||||
|
suspend fun getAddressById(addressId: String): PaymentAddressEntity?
|
||||||
|
|
||||||
@Query("DELETE FROM payment_addresses WHERE id = :id")
|
@Query("DELETE FROM payment_addresses WHERE id = :id")
|
||||||
suspend fun deleteAddressById(id: String)
|
suspend fun deleteAddressById(id: String)
|
||||||
@@ -76,51 +99,70 @@ interface ContactDao {
|
|||||||
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
||||||
|
|
||||||
// Delete a link
|
// Delete a link
|
||||||
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
|
||||||
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
|
||||||
|
|
||||||
// Delete all contact links for a payment
|
// Delete all contact links for a payment
|
||||||
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
|
||||||
suspend fun clearContactsForPayment(checkingId: String)
|
suspend fun clearContactsForPayment(paymentHash: String)
|
||||||
|
|
||||||
// Get all contacts linked to a payment
|
// Get all contacts linked to a payment
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT c.* FROM contacts c
|
SELECT c.* FROM contacts c
|
||||||
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
||||||
WHERE tcl.checkingId = :checkingId
|
WHERE tcl.paymentHash = :paymentHash
|
||||||
""")
|
""")
|
||||||
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
// Get all payments linked to a contact
|
// Get all payments linked to a contact
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT checkingId FROM tx_contact_links
|
SELECT paymentHash FROM tx_contact_links
|
||||||
WHERE contactId = :contactId
|
WHERE contactId = :contactId
|
||||||
ORDER BY createdAt DESC
|
ORDER BY createdAt DESC
|
||||||
""")
|
""")
|
||||||
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
||||||
|
|
||||||
// Returns all (checkingId, displayName) pairs that have a linked contact,
|
// Returns all (paymentHash, displayName) pairs that have a linked contact,
|
||||||
// so HistoryViewModel can build its lookup map in a single reactive query.
|
// so HistoryViewModel can build its lookup map in a single reactive query.
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT tcl.checkingId AS checkingId,
|
SELECT tcl.paymentHash AS paymentHash,
|
||||||
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
||||||
FROM tx_contact_links tcl
|
FROM tx_contact_links tcl
|
||||||
INNER JOIN contacts c ON c.id = tcl.contactId
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
""")
|
""")
|
||||||
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
||||||
|
|
||||||
// For text search matching contact names — returns checkingIds of matched payments
|
// Returns all (paymentHash, contactId, displayName) triples — used by
|
||||||
|
// PaymentFeedRepository to build full ContactSummary lists per payment.
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT tcl.checkingId FROM tx_contact_links tcl
|
SELECT tcl.paymentHash AS paymentHash,
|
||||||
|
c.id AS contactId,
|
||||||
|
COALESCE(c.localAlias, c.displayName, c.name) AS displayName
|
||||||
|
FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
""")
|
||||||
|
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>>
|
||||||
|
|
||||||
|
// For text search matching contact names — returns paymentHashes of matched payments
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.paymentHash FROM tx_contact_links tcl
|
||||||
INNER JOIN contacts c ON c.id = tcl.contactId
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
||||||
""")
|
""")
|
||||||
suspend fun findCheckingIdsByContactName(query: String): List<String>
|
suspend fun findPaymentHashesByContactName(query: String): List<String>
|
||||||
|
|
||||||
// For filter-by-contact — returns checkingIds for specific contact IDs
|
// For filter-by-contact — returns paymentHashs for specific contact IDs
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT DISTINCT checkingId FROM tx_contact_links
|
SELECT DISTINCT paymentHash FROM tx_contact_links
|
||||||
WHERE contactId IN (:contactIds)
|
WHERE contactId IN (:contactIds)
|
||||||
""")
|
""")
|
||||||
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String>
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
INNER JOIN tx_contact_links x ON c.id = x.contactId
|
||||||
|
WHERE x.paymentHash = :paymentHash
|
||||||
|
""")
|
||||||
|
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
data class PaymentContactName(
|
data class PaymentContactName(
|
||||||
val checkingId : String,
|
val paymentHash : String,
|
||||||
val contactName : String
|
val contactName : String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface PaymentDao {
|
interface PaymentDao {
|
||||||
|
|
||||||
@@ -13,10 +15,53 @@ interface PaymentDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(payment: PaymentRecordEntity)
|
suspend fun insert(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
// Safe upsert used by mergePayments only.
|
||||||
|
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
|
||||||
|
// the server-owned fields in-place — the parent row is never deleted,
|
||||||
|
// so the TxContactCrossRef CASCADE is never triggered.
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertIgnore(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_records SET
|
||||||
|
paymentHash = :paymentHash,
|
||||||
|
amountMsat = :amountMsat,
|
||||||
|
feeMsat = :feeMsat,
|
||||||
|
memo = :memo,
|
||||||
|
time = :time,
|
||||||
|
createdAt = :createdAt,
|
||||||
|
status = :status,
|
||||||
|
bolt11 = :bolt11,
|
||||||
|
pending = :pending,
|
||||||
|
preimage = :preimage,
|
||||||
|
extra = :extra,
|
||||||
|
savedAt = :savedAt
|
||||||
|
WHERE checkingId = :checkingId
|
||||||
|
""")
|
||||||
|
suspend fun updateSafe(
|
||||||
|
checkingId: String,
|
||||||
|
paymentHash: String,
|
||||||
|
amountMsat: Long,
|
||||||
|
feeMsat: Long,
|
||||||
|
memo: String?,
|
||||||
|
time: String,
|
||||||
|
createdAt: Long?,
|
||||||
|
status: String,
|
||||||
|
bolt11: String?,
|
||||||
|
pending: Boolean,
|
||||||
|
preimage: String?,
|
||||||
|
extra: String?,
|
||||||
|
savedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
/** Returns all rows ordered by time descending. */
|
/** Returns all rows ordered by time descending. */
|
||||||
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
||||||
suspend fun getAll(): List<PaymentRecordEntity>
|
suspend fun getAll(): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
/** Reactive version of [getAll] — emits a new list whenever any row changes. */
|
||||||
|
@Query("SELECT * FROM payment_records ORDER BY COALESCE(createdAt, CAST(time AS INTEGER)) DESC")
|
||||||
|
fun observeAll(): Flow<List<PaymentRecordEntity>>
|
||||||
|
|
||||||
/** A single page, ordered newest-first. */
|
/** A single page, ordered newest-first. */
|
||||||
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
|
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
|
||||||
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
|
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
|
||||||
@@ -35,6 +80,27 @@ interface PaymentDao {
|
|||||||
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
|
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
|
||||||
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
|
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM payment_records
|
||||||
|
WHERE paymentHash IN (:paymentHashes)
|
||||||
|
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
|
||||||
|
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
|
||||||
|
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
|
||||||
|
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
|
||||||
|
AND (:statusPattern IS NULL OR
|
||||||
|
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
||||||
|
ORDER BY createdAt DESC, time DESC
|
||||||
|
""")
|
||||||
|
suspend fun getByPaymentHashesFiltered(
|
||||||
|
paymentHashes : List<String>,
|
||||||
|
minAmountMsat : Long? = null,
|
||||||
|
maxAmountMsat : Long? = null,
|
||||||
|
minCreatedAt : Long? = null,
|
||||||
|
maxCreatedAt : Long? = null,
|
||||||
|
statusPattern : String? = null
|
||||||
|
): List<PaymentRecordEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM payment_records")
|
@Query("DELETE FROM payment_records")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,10 @@ import androidx.room.Index
|
|||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "tx_contact_links",
|
tableName = "tx_contact_links",
|
||||||
primaryKeys = ["checkingId", "contactId"],
|
primaryKeys = ["paymentHash", "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(
|
data class TxContactCrossRef(
|
||||||
val checkingId : String,
|
val paymentHash : String,
|
||||||
val contactId : String,
|
val contactId : String,
|
||||||
val role : String? = null,
|
val role : String? = null,
|
||||||
val createdAt : Long = System.currentTimeMillis()
|
val createdAt : Long = System.currentTimeMillis()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
data class ContactSummary(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String // localAlias → displayName → name, resolved at mapping time
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
|
||||||
|
data class PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────
|
||||||
|
val id: String, // = paymentHash; stable LazyColumn key
|
||||||
|
|
||||||
|
// ── Row (PaymentRow) ─────────────────────────────────────────────
|
||||||
|
val isOutgoing: Boolean,
|
||||||
|
val amountSat: Long,
|
||||||
|
val memo: String?,
|
||||||
|
val contactName: String?, // resolved: localAlias → displayName → name
|
||||||
|
val createdAt: Long?,
|
||||||
|
val time: Long?,
|
||||||
|
val isPending: Boolean,
|
||||||
|
val isFailed: Boolean,
|
||||||
|
val fiatCurrency: String?,
|
||||||
|
val fiatSatsPerUnit: Double?,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────
|
||||||
|
val paymentHash: String, // free-text search + technical detail
|
||||||
|
val tag: String?, // type filter (bolt11, keysend, etc.)
|
||||||
|
val contactIds: Set<String>, // contact-ID filter
|
||||||
|
|
||||||
|
// ── Detail (PaymentDetailScreen) — amounts ───────────────────────
|
||||||
|
val feeSat: Long?, // shown if > 0
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────
|
||||||
|
val linkedContacts: List<ContactSummary>,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────
|
||||||
|
val comment: String?,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────
|
||||||
|
val successActionMessage: String?,
|
||||||
|
val successActionUrl: String?,
|
||||||
|
val successActionDescription: String?,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────
|
||||||
|
val preimage: String?,
|
||||||
|
val pubkey: String?,
|
||||||
|
val alias: String?, // live from NodeAliasCache, fallback to snapshot
|
||||||
|
val bolt11: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs a minimal PaymentRecord from a PaymentFeedItem for use as
|
||||||
|
* a fallback in PaymentDetailScreen while the detail fetch is in flight.
|
||||||
|
* Fields not stored on PaymentFeedItem (checkingId, extra deserialized) are
|
||||||
|
* set to safe defaults — they will be replaced by the enriched record once
|
||||||
|
* DetailState.Success arrives.
|
||||||
|
*/
|
||||||
|
fun PaymentFeedItem.toBaseRecord(): PaymentRecord = PaymentRecord(
|
||||||
|
checkingId = paymentHash, // best approximation — checkingId not stored on feed item
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
amountMsat = if (isOutgoing) -(amountSat * 1000L) else amountSat * 1000L,
|
||||||
|
feeMsat = (feeSat ?: 0L) * 1000L,
|
||||||
|
memo = memo,
|
||||||
|
time = time?.toString() ?: "",
|
||||||
|
createdAt = createdAt,
|
||||||
|
status = when {
|
||||||
|
isFailed -> "failed"
|
||||||
|
isPending -> "pending"
|
||||||
|
else -> "complete"
|
||||||
|
},
|
||||||
|
bolt11 = bolt11,
|
||||||
|
pending = isPending,
|
||||||
|
preimage = preimage,
|
||||||
|
extra = null, // deserialized extra not stored on PaymentFeedItem
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = alias
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
// Placeholder preimage that LNbits returns when the real one isn't available yet
|
||||||
|
private const val PREIMAGE_PLACEHOLDER = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
// Lightweight tag/comment extraction from raw extra JSON —
|
||||||
|
// avoids full PaymentExtra deserialization on every list emission.
|
||||||
|
// Falls back gracefully if the JSON is malformed or fields are absent.
|
||||||
|
private fun extractTag(extraJson: String?): String? {
|
||||||
|
if (extraJson == null) return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(extraJson).jsonObject["tag"]?.jsonPrimitive?.content
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractComment(extraJson: String?): String? {
|
||||||
|
if (extraJson == null) return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(extraJson).jsonObject["comment"]?.jsonPrimitive?.content
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContactEntity → ContactSummary ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fun ContactEntity.toContactSummary(): ContactSummary = ContactSummary(
|
||||||
|
id = id,
|
||||||
|
displayName = localAlias
|
||||||
|
?: displayName
|
||||||
|
?: name
|
||||||
|
?: id // last resort — never shown as empty
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PaymentRecord → PaymentFeedItem ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── PaymentRecordEntity → PaymentFeedItem ─────────────────────────────────────
|
||||||
|
// Direct mapper — bypasses toDomain() to avoid PaymentRecord allocation
|
||||||
|
// and full PaymentExtra JSON deserialization on every feed emission.
|
||||||
|
|
||||||
|
fun PaymentRecordEntity.toPaymentFeedItem(
|
||||||
|
linkedContacts : List<ContactSummary>,
|
||||||
|
resolvedAlias : String?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
fiatSatsPerUnit : Double?
|
||||||
|
): PaymentFeedItem {
|
||||||
|
val isOutgoing = amountMsat < 0
|
||||||
|
val tag = extractTag(extra)
|
||||||
|
val comment = extractComment(extra)
|
||||||
|
|
||||||
|
return PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────────
|
||||||
|
id = paymentHash,
|
||||||
|
|
||||||
|
// ── Row ──────────────────────────────────────────────────────────────
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
amountSat = Math.abs(amountMsat) / 1000L,
|
||||||
|
memo = memo,
|
||||||
|
contactName = linkedContacts.firstOrNull()?.displayName,
|
||||||
|
createdAt = createdAt,
|
||||||
|
time = time.toLongOrNull(),
|
||||||
|
isPending = pending,
|
||||||
|
isFailed = status == "failed",
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────────
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
tag = tag,
|
||||||
|
contactIds = linkedContacts.map { it.id }.toSet(),
|
||||||
|
|
||||||
|
// ── Detail — amounts ─────────────────────────────────────────────────
|
||||||
|
feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null,
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────────
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────────
|
||||||
|
comment = comment,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────────
|
||||||
|
// Not extracted in the list mapper — PaymentDetailDelegate loads full
|
||||||
|
// extra via toDomain() when the detail screen is opened.
|
||||||
|
successActionMessage = null,
|
||||||
|
successActionUrl = null,
|
||||||
|
successActionDescription = null,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────────
|
||||||
|
preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER },
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = resolvedAlias ?: alias,
|
||||||
|
bolt11 = bolt11,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentRecord.toPaymentFeedItem(
|
||||||
|
linkedContacts: List<ContactSummary>,
|
||||||
|
resolvedAlias: String?, // from NodeAliasCache; fallback to payment.alias handled here
|
||||||
|
fiatCurrency: String?,
|
||||||
|
fiatSatsPerUnit: Double?
|
||||||
|
): PaymentFeedItem {
|
||||||
|
|
||||||
|
val isOutgoing = amountMsat < 0
|
||||||
|
|
||||||
|
return PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────────
|
||||||
|
id = paymentHash,
|
||||||
|
|
||||||
|
// ── Row ──────────────────────────────────────────────────────────────
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
amountSat = Math.abs(amountMsat) / 1000L,
|
||||||
|
memo = memo,
|
||||||
|
contactName = linkedContacts.firstOrNull()?.displayName,
|
||||||
|
createdAt = createdAt,
|
||||||
|
time = time.toLongOrNull(),
|
||||||
|
isPending = pending,
|
||||||
|
isFailed = status == "failed",
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────────
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
tag = extra?.tag,
|
||||||
|
contactIds = linkedContacts.map { it.id }.toSet(),
|
||||||
|
|
||||||
|
// ── Detail — amounts ─────────────────────────────────────────────────
|
||||||
|
feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null,
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────────
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────────
|
||||||
|
comment = extra?.comment,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────────
|
||||||
|
successActionMessage = extra?.successAction?.message,
|
||||||
|
successActionUrl = extra?.successAction?.url,
|
||||||
|
successActionDescription = extra?.successAction?.description,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────────
|
||||||
|
preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER },
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = resolvedAlias ?: alias, // live cache first, snapshot fallback
|
||||||
|
bolt11 = bolt11,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,13 +23,13 @@ sealed class ReceiveState {
|
|||||||
val amountSats : Long,
|
val amountSats : Long,
|
||||||
val memo : String,
|
val memo : String,
|
||||||
val expiresAt : Long
|
val expiresAt : Long
|
||||||
) : ReceiveState()
|
) : ReceiveState()
|
||||||
data class PaymentReceived(
|
data class PaymentReceived(
|
||||||
val amountSats: Long,
|
val amountSats: Long,
|
||||||
val memo : String?,
|
val memo : String?,
|
||||||
val checkingId : String
|
val paymentHash : String
|
||||||
) : ReceiveState()
|
) : ReceiveState()
|
||||||
data class Error(val message: String) : ReceiveState()
|
data class Error(val message: String) : ReceiveState()
|
||||||
data class LnurlWithdrawReady(
|
data class LnurlWithdrawReady(
|
||||||
val k1 : String,
|
val k1 : String,
|
||||||
val callback : String,
|
val callback : String,
|
||||||
@@ -85,14 +85,18 @@ sealed class SendState {
|
|||||||
override val routeHintAliases : Map<String, String> = emptyMap(),
|
override val routeHintAliases : Map<String, String> = emptyMap(),
|
||||||
override val paymentHash : String,
|
override val paymentHash : String,
|
||||||
override val createdAt : Instant,
|
override val createdAt : Instant,
|
||||||
override val expiresAt : Instant
|
override val expiresAt : Instant,
|
||||||
|
val contactId : String? = null
|
||||||
) : SendState(), DecodedInvoice
|
) : SendState(), DecodedInvoice
|
||||||
data object Paying : SendState()
|
data object Paying : SendState()
|
||||||
data class PaymentSent(
|
data class PaymentSent(
|
||||||
val amountSats : Long,
|
val amountSats : Long,
|
||||||
val feeSats : Long? = null,
|
val feeSats : Long? = null,
|
||||||
val paymentHash: String,
|
val paymentHash: String,
|
||||||
val pendingRecord: PaymentRecord? = null
|
val pendingRecord: PaymentRecord? = null,
|
||||||
|
val lnurl : String? = null,
|
||||||
|
val contactId : String? = null,
|
||||||
|
val contactName : String? = null
|
||||||
) : SendState()
|
) : SendState()
|
||||||
data class Error(val message: String) : SendState()
|
data class Error(val message: String) : SendState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui
|
package com.bitcointxoko.gudariwallet.ui
|
||||||
|
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -72,7 +74,7 @@ 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.data.PaymentFeedRepository
|
||||||
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -90,13 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
|
|||||||
private const val ROUTE_HOME = "home"
|
private const val ROUTE_HOME = "home"
|
||||||
private const val ROUTE_SCANNER = "scanner"
|
private const val ROUTE_SCANNER = "scanner"
|
||||||
private const val ROUTE_HISTORY = "history"
|
private const val ROUTE_HISTORY = "history"
|
||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
|
||||||
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_CONTACTS = "contacts"
|
||||||
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||||
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
@@ -131,10 +134,17 @@ fun WalletScreen(
|
|||||||
aliasRepo = vm.aliasRepo,
|
aliasRepo = vm.aliasRepo,
|
||||||
paymentCache = vm.paymentCache,
|
paymentCache = vm.paymentCache,
|
||||||
contactRepo = vm.contactRepo,
|
contactRepo = vm.contactRepo,
|
||||||
|
feedRepository = PaymentFeedRepository(
|
||||||
|
paymentCache = vm.paymentCache,
|
||||||
|
contactRepo = vm.contactRepo,
|
||||||
|
nodeAliasRepository = vm.aliasRepo,
|
||||||
|
fiatVm = vm.fiatVm
|
||||||
|
),
|
||||||
fiatCurrency = vm.selectedCurrency,
|
fiatCurrency = vm.selectedCurrency,
|
||||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||||
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
|
||||||
@@ -367,18 +377,23 @@ fun WalletScreen(
|
|||||||
ReceiveScreen(
|
ReceiveScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
nfcVm = nfcVm,
|
nfcVm = nfcVm,
|
||||||
onNavigateToPaymentDetail = { checkingId ->
|
onNavigateToPaymentDetail = { paymentHash ->
|
||||||
navController.navigate("payment_detail/$checkingId")
|
navController.navigate("payment_detail/$paymentHash")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(TabItem.Send.route) {
|
composable(TabItem.Send.route) {
|
||||||
SendScreen(
|
SendScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
nfcVm = nfcVm,
|
nfcVm = nfcVm,
|
||||||
onViewDetails = { checkingId, record ->
|
onViewDetails = { paymentHash, record ->
|
||||||
historyVm.primePayment(record)
|
historyVm.primePayment(record)
|
||||||
navController.navigate("payment_detail/$checkingId") {
|
navController.navigate("payment_detail/$paymentHash") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onViewContact = { contactId ->
|
||||||
|
navController.navigate("contacts/$contactId") {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -417,34 +432,30 @@ fun WalletScreen(
|
|||||||
onClose = {
|
onClose = {
|
||||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
},
|
},
|
||||||
onPaymentClick = { payment ->
|
onPaymentClick = { item ->
|
||||||
navController.navigate("payment_detail/${payment.checkingId}")
|
navController.navigate("payment_detail/${item.paymentHash}")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment detail — back → history (natural back stack)
|
// Payment detail — back → history (natural back stack)
|
||||||
composable(
|
composable(
|
||||||
route = ROUTE_PAYMENT_DETAIL,
|
route = ROUTE_PAYMENT_DETAIL,
|
||||||
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
|
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
|
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
|
||||||
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
|
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
|
||||||
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
|
val item = remember(feedItems, paymentHash) {
|
||||||
val payment = remember(historyState, fallbackState, checkingId) {
|
historyVm.findPayment(paymentHash)
|
||||||
(historyState as? HistoryState.Success)
|
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
|
||||||
?: (fallbackState as? HistoryState.Success)
|
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
|
||||||
}
|
}
|
||||||
if (payment != null) {
|
if (item != null) {
|
||||||
PaymentDetailScreen(
|
PaymentDetailScreen(
|
||||||
payment = payment,
|
item = item,
|
||||||
vm = historyVm,
|
vm = historyVm,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LaunchedEffect(checkingId) { historyVm.refresh() }
|
LaunchedEffect(paymentHash) { historyVm.refresh() }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -537,19 +548,30 @@ fun WalletScreen(
|
|||||||
composable(
|
composable(
|
||||||
route = ROUTE_CONTACT_DETAIL,
|
route = ROUTE_CONTACT_DETAIL,
|
||||||
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
||||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
||||||
val detailVm: ContactDetailViewModel = viewModel(
|
val detailVm: ContactDetailViewModel = viewModel(
|
||||||
key = contactId,
|
key = contactId,
|
||||||
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
||||||
)
|
)
|
||||||
ContactDetailScreen(
|
ContactDetailScreen(
|
||||||
viewModel = detailVm,
|
viewModel = detailVm,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() },
|
||||||
|
onPayAddress = { address ->
|
||||||
|
vm.prefillSend(address)
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = false // don't restore stale SendScreen state
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clipboard banner — floats over content, slides in from top ────
|
// ── Clipboard banner — floats over content, slides in from top ────
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -104,7 +105,8 @@ class WalletViewModel(
|
|||||||
amountMsat: Long,
|
amountMsat: Long,
|
||||||
comment : String?,
|
comment : String?,
|
||||||
strings : SendStrings,
|
strings : SendStrings,
|
||||||
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings)
|
contactId : String?
|
||||||
|
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings, contactId)
|
||||||
|
|
||||||
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
|
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
|
||||||
sendVm.payLnurlInvoice(state, strings)
|
sendVm.payLnurlInvoice(state, strings)
|
||||||
@@ -116,6 +118,16 @@ class WalletViewModel(
|
|||||||
|
|
||||||
val sendEvents: SharedFlow<SendEvent> = sendVm.events
|
val sendEvents: SharedFlow<SendEvent> = sendVm.events
|
||||||
|
|
||||||
|
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
|
||||||
|
private val _pendingSend = MutableStateFlow<String?>(null)
|
||||||
|
val pendingSend: StateFlow<String?> get() = _pendingSend
|
||||||
|
|
||||||
|
/** Called by ContactDetailScreen before navigating to the Send tab. */
|
||||||
|
fun prefillSend(address: String) { _pendingSend.value = address }
|
||||||
|
|
||||||
|
/** Called by SendScreen once it has consumed the prefilled address. */
|
||||||
|
fun consumePendingSend() { _pendingSend.value = null }
|
||||||
|
|
||||||
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
|
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
|
||||||
receiveVm.handleWithdrawResponse(raw, lnurl)
|
receiveVm.handleWithdrawResponse(raw, lnurl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
scanner = lnurlScanner,
|
scanner = lnurlScanner,
|
||||||
payer = lnurlPayer,
|
payer = lnurlPayer,
|
||||||
poller = poller,
|
poller = poller,
|
||||||
|
contactRepo = contactRepo,
|
||||||
|
paymentCacheRepo = paymentCache
|
||||||
)
|
)
|
||||||
|
|
||||||
val clipboardVm = ClipboardViewModel(
|
val clipboardVm = ClipboardViewModel(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
// ── Contact avatar ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun String.toHslColor(
|
||||||
|
saturation: Float = 0.5f,
|
||||||
|
lightness: Float = 0.4f
|
||||||
|
): Color {
|
||||||
|
val hue = fold(0) { acc, char -> char.code + acc * 37 } % 360
|
||||||
|
return Color.hsl(abs(hue).toFloat(), saturation, lightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactAvatar(
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 40.dp
|
||||||
|
) {
|
||||||
|
val bgColor = remember(name) {
|
||||||
|
name.toHslColor()
|
||||||
|
}
|
||||||
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawCircle(SolidColor(bgColor))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = initial,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared success screen used by both Send (PaymentSent) and Receive (PaymentReceived).
|
||||||
|
*
|
||||||
|
* @param title Headline string, e.g. strings.paymentSent / strings.receive.paymentReceived
|
||||||
|
* @param amountSats Amount in sats.
|
||||||
|
* @param fiatLabel Optional fiat conversion string (from [fiatLabel] util).
|
||||||
|
* @param subLine Optional secondary line below amount: fee text (send) or memo (receive).
|
||||||
|
* @param detailsEnabled Whether the Details button is clickable.
|
||||||
|
* @param onDetails Navigate to payment detail.
|
||||||
|
* @param secondaryLabel Label of the reset TextButton ("Send Another" / "Done").
|
||||||
|
* @param onSecondary Reset screen state.
|
||||||
|
* @param lnurl Pre-fill address for the save-contact sheet. Pass null to hide the button.
|
||||||
|
* @param onSaveContact Forward to vm.saveContactFromSend — omit (null) to hide save-contact UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PaymentSuccessContent(
|
||||||
|
title : String,
|
||||||
|
amountSats : Long,
|
||||||
|
fiatLabel : String?,
|
||||||
|
subLine : String? = null,
|
||||||
|
detailsEnabled : Boolean = true,
|
||||||
|
onDetails : () -> Unit,
|
||||||
|
secondaryLabel : String,
|
||||||
|
onSecondary : () -> Unit,
|
||||||
|
lnurl : String? = null,
|
||||||
|
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
|
||||||
|
onViewContact : ((contactId: String) -> Unit)? = null,
|
||||||
|
contactId : String? = null,
|
||||||
|
contactName : String? = null
|
||||||
|
) {
|
||||||
|
var showSaveSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||||
|
if (!subLine.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = subLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
// ── Save contact affordance (send-side only) ─────────────────────────
|
||||||
|
when {
|
||||||
|
contactName != null -> {
|
||||||
|
// Contact already known — show their name
|
||||||
|
val canNavigate = contactId != null && onViewContact != null
|
||||||
|
AssistChip(
|
||||||
|
onClick = { if (canNavigate) onViewContact!!(contactId!!) },
|
||||||
|
label = { Text(contactName) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(AssistChipDefaults.IconSize)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canNavigate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lnurl != null && onSaveContact != null -> {
|
||||||
|
// Unknown sender — offer to save
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showSaveSheet = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Save contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onDetails,
|
||||||
|
enabled = detailsEnabled,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text(LocalAppStrings.current.details) }
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onSecondary,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text(secondaryLabel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bottom sheet ─────────────────────────────────────────────────────────
|
||||||
|
if (showSaveSheet && lnurl != null && onSaveContact != null) {
|
||||||
|
// Detect whether the string is a Lightning Address (user@domain) or LNURL
|
||||||
|
val isLnAddress = lnurl.contains('@') && !lnurl.startsWith("lnurl", ignoreCase = true)
|
||||||
|
val initialType = if (isLnAddress)
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
else
|
||||||
|
PaymentAddressType.LNURL
|
||||||
|
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddress = lnurl,
|
||||||
|
initialAddressType = initialType,
|
||||||
|
onSave = { name, addressType, address ->
|
||||||
|
onSaveContact(name, addressType, address)
|
||||||
|
showSaveSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showSaveSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+349
-66
@@ -1,8 +1,9 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.contacts
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
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.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -36,7 +37,8 @@ import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
|||||||
@Composable
|
@Composable
|
||||||
fun ContactDetailScreen(
|
fun ContactDetailScreen(
|
||||||
viewModel: ContactDetailViewModel,
|
viewModel: ContactDetailViewModel,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit,
|
||||||
|
onPayAddress: (address: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -44,6 +46,10 @@ fun ContactDetailScreen(
|
|||||||
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
||||||
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
|
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -68,22 +74,25 @@ fun ContactDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (uiState is ContactDetailUiState.Success) {
|
if (uiState is ContactDetailUiState.Success) {
|
||||||
IconButton(onClick = { viewModel.openEditAliasSheet() }) {
|
if (isEditing) {
|
||||||
Icon(Icons.Default.Edit, contentDescription = "Edit name")
|
// Done button exits edit mode
|
||||||
}
|
TextButton(onClick = { isEditing = false }) {
|
||||||
IconButton(onClick = { viewModel.requestDelete() }) {
|
Text("Done")
|
||||||
Icon(
|
}
|
||||||
Icons.Default.Delete,
|
} else {
|
||||||
contentDescription = "Delete contact",
|
// Edit button opens edit mode
|
||||||
tint = MaterialTheme.colorScheme.error
|
IconButton(onClick = { isEditing = true }) {
|
||||||
)
|
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (uiState is ContactDetailUiState.Success) {
|
// FAB only visible in edit mode so users don't accidentally add
|
||||||
|
// addresses while browsing
|
||||||
|
if (uiState is ContactDetailUiState.Success && isEditing) {
|
||||||
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
||||||
}
|
}
|
||||||
@@ -93,14 +102,18 @@ fun ContactDetailScreen(
|
|||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
is ContactDetailUiState.Loading -> {
|
is ContactDetailUiState.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().padding(innerPadding),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator() }
|
||||||
}
|
}
|
||||||
|
|
||||||
is ContactDetailUiState.NotFound -> {
|
is ContactDetailUiState.NotFound -> {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().padding(innerPadding),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -113,9 +126,14 @@ fun ContactDetailScreen(
|
|||||||
is ContactDetailUiState.Success -> {
|
is ContactDetailUiState.Success -> {
|
||||||
ContactDetailContent(
|
ContactDetailContent(
|
||||||
state = state,
|
state = state,
|
||||||
onRemoveAddress = { viewModel.removeAddress(it) },
|
isEditing = isEditing,
|
||||||
|
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
|
||||||
onSetDefault = { viewModel.setDefaultAddress(it) },
|
onSetDefault = { viewModel.setDefaultAddress(it) },
|
||||||
modifier = Modifier.padding(innerPadding)
|
onPayAddress = onPayAddress,
|
||||||
|
onEditAlias = { viewModel.openEditAliasSheet() },
|
||||||
|
onEditAddress = { addr -> editingAddress = addr },
|
||||||
|
onDeleteContact = { viewModel.requestDelete() },
|
||||||
|
modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,8 +143,8 @@ fun ContactDetailScreen(
|
|||||||
if (pendingDelete) {
|
if (pendingDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { viewModel.cancelDelete() },
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
title = { Text("Delete contact") },
|
title = { Text("Delete contact") },
|
||||||
text = { Text("This contact and all their addresses will be permanently removed.") },
|
text = { Text("This contact and all their addresses will be permanently removed.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
@@ -140,6 +158,27 @@ fun ContactDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingAddressDelete?.let { addressId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingAddressDelete = null },
|
||||||
|
title = { Text("Remove address") },
|
||||||
|
text = { Text("This payment address will be permanently removed from the contact.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.removeAddress(addressId)
|
||||||
|
pendingAddressDelete = null
|
||||||
|
}) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingAddressDelete = null }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Add address sheet ─────────────────────────────────────────────────
|
// ── Add address sheet ─────────────────────────────────────────────────
|
||||||
if (showAddAddressSheet) {
|
if (showAddAddressSheet) {
|
||||||
AddAddressSheet(
|
AddAddressSheet(
|
||||||
@@ -151,6 +190,18 @@ fun ContactDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit address sheet ──────────────────────────────────────────
|
||||||
|
editingAddress?.let { addr ->
|
||||||
|
EditAddressSheet(
|
||||||
|
address = addr,
|
||||||
|
onSave = { id, type, addressValue, label, makeDefault ->
|
||||||
|
viewModel.updateAddress(id, type, addressValue, label, makeDefault)
|
||||||
|
editingAddress = null
|
||||||
|
},
|
||||||
|
onDismiss = { editingAddress = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Edit alias sheet ──────────────────────────────────────────────────
|
// ── Edit alias sheet ──────────────────────────────────────────────────
|
||||||
if (showEditAliasSheet) {
|
if (showEditAliasSheet) {
|
||||||
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
||||||
@@ -171,8 +222,13 @@ fun ContactDetailScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ContactDetailContent(
|
private fun ContactDetailContent(
|
||||||
state: ContactDetailUiState.Success,
|
state: ContactDetailUiState.Success,
|
||||||
|
isEditing: Boolean,
|
||||||
onRemoveAddress: (addressId: String) -> Unit,
|
onRemoveAddress: (addressId: String) -> Unit,
|
||||||
onSetDefault: (PaymentAddressEntity) -> Unit,
|
onSetDefault: (PaymentAddressEntity) -> Unit,
|
||||||
|
onPayAddress: (address: String) -> Unit,
|
||||||
|
onEditAlias: () -> Unit,
|
||||||
|
onEditAddress: (PaymentAddressEntity) -> Unit,
|
||||||
|
onDeleteContact: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val contact = state.data.contact
|
val contact = state.data.contact
|
||||||
@@ -225,29 +281,33 @@ private fun ContactDetailContent(
|
|||||||
|
|
||||||
// ── Payment addresses section ─────────────────────────────────────
|
// ── Payment addresses section ─────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = "Payment addresses") {}
|
DetailSection(title = "Payment addresses") {
|
||||||
}
|
if (addresses.isEmpty()) {
|
||||||
|
Text(
|
||||||
if (addresses.isEmpty()) {
|
text = "No addresses added yet",
|
||||||
item {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
text = "No addresses added yet",
|
)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
} else {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
addresses.forEachIndexed { index, addr ->
|
||||||
)
|
AddressRow(
|
||||||
}
|
address = addr,
|
||||||
} else {
|
isEditing = isEditing,
|
||||||
items(addresses, key = { it.id }) { addr ->
|
onPay = { onPayAddress(addr.address) },
|
||||||
AddressRow(
|
onRemove = { onRemoveAddress(addr.id) },
|
||||||
address = addr,
|
onSetDefault = { onSetDefault(addr) },
|
||||||
onRemove = { onRemoveAddress(addr.id) },
|
onEdit = { onEditAddress(addr) }
|
||||||
onSetDefault = { onSetDefault(addr) }
|
)
|
||||||
)
|
if (index < addresses.lastIndex) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
thickness = 0.5.dp
|
thickness = 0.5.dp
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +319,47 @@ private fun ContactDetailContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Destructive actions (edit mode only) ─────────────────────────
|
||||||
|
if (isEditing) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onEditAlias,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Edit name")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDeleteContact,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Delete contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,8 +368,11 @@ private fun ContactDetailContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AddressRow(
|
private fun AddressRow(
|
||||||
address: PaymentAddressEntity,
|
address: PaymentAddressEntity,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onPay: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onSetDefault: () -> Unit
|
onSetDefault: () -> Unit,
|
||||||
|
onEdit: () -> Unit
|
||||||
) {
|
) {
|
||||||
val typeLabel = when (address.type) {
|
val typeLabel = when (address.type) {
|
||||||
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
||||||
@@ -279,46 +383,95 @@ private fun AddressRow(
|
|||||||
PaymentAddressType.BIP353.name -> "BIP-353"
|
PaymentAddressType.BIP353.name -> "BIP-353"
|
||||||
else -> address.type
|
else -> address.type
|
||||||
}
|
}
|
||||||
val label = address.label?.let { "$typeLabel · $it" } ?: typeLabel
|
|
||||||
|
|
||||||
DetailRow(
|
Row(
|
||||||
label = label,
|
modifier = Modifier
|
||||||
value = address.address,
|
.fillMaxWidth()
|
||||||
maxLines = 1,
|
.clickable(
|
||||||
trailingContent = {
|
enabled = !isEditing,
|
||||||
Row {
|
onClickLabel = "Pay with this address"
|
||||||
// Set default star — filled if already default
|
) { onPay() }
|
||||||
IconButton(
|
.padding(vertical = 12.dp),
|
||||||
onClick = onSetDefault,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
modifier = Modifier.size(32.dp)
|
) {
|
||||||
) {
|
// ── Label + value ─────────────────────────────────────────────
|
||||||
Icon(
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
imageVector = if (address.isDefault) Icons.Filled.Star
|
Text(
|
||||||
else Icons.Outlined.Star,
|
text = address.address,
|
||||||
contentDescription = if (address.isDefault) "Default address"
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
else "Set as default",
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.size(16.dp),
|
maxLines = 1,
|
||||||
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
|
overflow = TextOverflow.Ellipsis
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = typeLabel,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
address.label?.let { lbl ->
|
||||||
|
Text(
|
||||||
|
text = "· $lbl",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (address.isDefault) {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"default",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.height(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trailing actions ──────────────────────────────────────────
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
// Copy always available
|
||||||
|
CopyIconButton(
|
||||||
|
label = "Copy address",
|
||||||
|
value = address.address,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
// Star + delete only in edit mode
|
||||||
|
if (isEditing) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onEdit,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit address",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Remove
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Delete,
|
imageVector = Icons.Default.Delete,
|
||||||
contentDescription = "Remove address",
|
contentDescription = "Remove address",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Add address sheet ──────────────────────────────────────────────────────────
|
// ── Add address sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -444,6 +597,136 @@ private fun AddAddressSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit address sheet ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EditAddressSheet(
|
||||||
|
address: PaymentAddressEntity,
|
||||||
|
onSave: (id: String, type: PaymentAddressType, addressValue: 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.entries.find { it.name == address.type }
|
||||||
|
?: PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
}
|
||||||
|
var addressValue by remember { mutableStateOf(address.address) }
|
||||||
|
var label by remember { mutableStateOf(address.label ?: "") }
|
||||||
|
var makeDefault by remember { mutableStateOf(address.isDefault) }
|
||||||
|
|
||||||
|
val canSave = addressValue.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("Edit 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 },
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LNURL },
|
||||||
|
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 = addressValue,
|
||||||
|
onValueChange = { addressValue = 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(address.id, selectedType, addressValue, 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(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ class ContactDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
makeDefault: Boolean
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.updateAddress(addressId, type, address, label, makeDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun removeAddress(addressId: String) {
|
fun removeAddress(addressId: String) {
|
||||||
viewModelScope.launch { repo.removeAddress(addressId) }
|
viewModelScope.launch { repo.removeAddress(addressId) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,26 @@ import androidx.compose.foundation.lazy.items
|
|||||||
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.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -29,6 +38,8 @@ fun ContactsScreen(
|
|||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var showCreateSheet by remember { mutableStateOf(false) }
|
var showCreateSheet by remember { mutableStateOf(false) }
|
||||||
|
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -41,6 +52,17 @@ fun ContactsScreen(
|
|||||||
contentDescription = strings.back
|
contentDescription = strings.back
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
searchVisible = !searchVisible
|
||||||
|
if (!searchVisible) searchQuery = ""
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search contacts"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -50,58 +72,96 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when (val state = uiState) {
|
val focusRequester = remember { FocusRequester() }
|
||||||
is ContactsUiState.Loading -> {
|
val clipboard = LocalClipboard.current
|
||||||
Box(
|
val scope = rememberCoroutineScope()
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
LaunchedEffect(searchVisible) {
|
||||||
.padding(innerPadding),
|
if (searchVisible) {
|
||||||
contentAlignment = Alignment.Center
|
// kotlinx.coroutines.delay(50)
|
||||||
) {
|
focusRequester.requestFocus()
|
||||||
CircularProgressIndicator()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
if (searchVisible) {
|
||||||
|
ContactsSearchBar(
|
||||||
|
query = searchQuery,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
clipboard = clipboard,
|
||||||
|
scope = scope,
|
||||||
|
onQueryChange = { searchQuery = it },
|
||||||
|
onClear = { searchQuery = "" }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ContactsUiState.Success -> {
|
when (val state = uiState) {
|
||||||
if (state.contacts.isEmpty()) {
|
is ContactsUiState.Loading -> {
|
||||||
ContactsEmptyContent(
|
Box(
|
||||||
onAdd = { showCreateSheet = true },
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier
|
contentAlignment = Alignment.Center
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
items(state.contacts, key = { it.contact.id }) { item ->
|
CircularProgressIndicator()
|
||||||
ContactRow(item, onClick = { onContactClick(item.contact.id) })
|
}
|
||||||
HorizontalDivider(
|
}
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
thickness = 0.5.dp
|
is ContactsUiState.Success -> {
|
||||||
)
|
val filteredContacts = remember(state.contacts, searchQuery) {
|
||||||
|
if (searchQuery.isBlank()) state.contacts
|
||||||
|
else state.contacts.filter { item ->
|
||||||
|
val contact = item.contact
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: ""
|
||||||
|
val addressMatch = item.addresses.any { addr ->
|
||||||
|
addr.address.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
name.contains(searchQuery, ignoreCase = true) || addressMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredContacts.isEmpty()) {
|
||||||
|
ContactsEmptyContent(
|
||||||
|
onAdd = { showCreateSheet = true },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(filteredContacts, 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) {
|
if (showCreateSheet) {
|
||||||
CreateContactSheet(
|
CreateContactSheet(
|
||||||
onSave = { name, addressType, address ->
|
onSave = { name, addressType, address ->
|
||||||
viewModel.createContact(name, addressType, address)
|
viewModel.createContact(name, addressType, address)
|
||||||
showCreateSheet = false
|
showCreateSheet = false
|
||||||
},
|
},
|
||||||
onDismiss = { showCreateSheet = false }
|
onDismiss = { showCreateSheet = false }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Empty state ────────────────────────────────────────────────────────────────
|
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactsEmptyContent(
|
private fun ContactsEmptyContent(
|
||||||
@@ -140,7 +200,65 @@ private fun ContactsEmptyContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contact row ────────────────────────────────────────────────────────────────
|
// ── Search bar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsSearchBar(
|
||||||
|
query: String,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
clipboard: androidx.compose.ui.platform.Clipboard,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Search contacts") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val pasted = clipboard.getClipEntry()
|
||||||
|
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
|
||||||
|
onQueryChange(pasted)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = "Paste"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onClear,
|
||||||
|
enabled = query.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactRow(
|
private fun ContactRow(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -50,6 +51,7 @@ class ContactsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
|
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -95,6 +96,17 @@ class FiatViewModel(
|
|||||||
initialValue = null
|
initialValue = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Combined fiat stream for [PaymentFeedRepository] feed assembly. */
|
||||||
|
val fiatFlow: StateFlow<Pair<String?, Double?>> = combine(
|
||||||
|
selectedCurrency,
|
||||||
|
fiatSatsPerUnit
|
||||||
|
) { currency, rate -> Pair(currency, rate) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = Pair(null, null)
|
||||||
|
)
|
||||||
|
|
||||||
// ── Called by BalanceViewModel after every successful balance refresh ─────
|
// ── Called by BalanceViewModel after every successful balance refresh ─────
|
||||||
fun onBalanceRefreshed() {
|
fun onBalanceRefreshed() {
|
||||||
viewModelScope.launch { fetchAndApplyFiatRate() }
|
viewModelScope.launch { fetchAndApplyFiatRate() }
|
||||||
|
|||||||
@@ -56,46 +56,47 @@ import androidx.compose.ui.focus.focusRequester
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── List screen ──────────────────────────────────────────────────────────────
|
// ── List screen ───────────────────────────────────────────────────────────────
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(
|
fun HistoryScreen(
|
||||||
vm : HistoryViewModel,
|
vm : HistoryViewModel,
|
||||||
onClose : () -> Unit,
|
onClose : () -> Unit,
|
||||||
onPaymentClick : (PaymentRecord) -> Unit
|
onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val state by vm.filteredState.collectAsState()
|
val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
|
||||||
val filter by vm.filter.collectAsState()
|
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
|
||||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
val filter by vm.filter.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) }
|
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
|
||||||
val dismissFilterSheet = { filterSheetVisible = false }
|
// they are now embedded in each PaymentFeedItem
|
||||||
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val dismissFilterSheet = { filterSheetVisible = false }
|
||||||
|
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchInfoSheetVisible by remember { mutableStateOf(false) }
|
var searchInfoSheetVisible by remember { mutableStateOf(false) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
val isFiltered = filter.isActive
|
val isFiltered = filter.isActive
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(strings.history.historyTitle) }, // ← "History"
|
title = { Text(strings.history.historyTitle) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.historyClose // ← "Close"
|
contentDescription = strings.history.historyClose
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -109,7 +110,7 @@ fun HistoryScreen(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
contentDescription = strings.history.historySearchPayments // ← "Search payments"
|
contentDescription = strings.history.historySearchPayments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +120,7 @@ fun HistoryScreen(
|
|||||||
IconButton(onClick = { filterSheetVisible = true }) {
|
IconButton(onClick = { filterSheetVisible = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FilterList,
|
imageVector = Icons.Default.FilterList,
|
||||||
contentDescription = strings.history.historyFilterPayments // ← "Filter payments"
|
contentDescription = strings.history.historyFilterPayments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ fun HistoryScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
// ── Search bar ───────────────────────────────────────────────────
|
// ── Search bar ─────────────────────────────────────────────────
|
||||||
if (searchVisible) {
|
if (searchVisible) {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = filter.searchQuery,
|
query = filter.searchQuery,
|
||||||
@@ -154,7 +155,7 @@ fun HistoryScreen(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active filter chips ──────────────────────────────────────────
|
// ── Active filter chips ────────────────────────────────────────
|
||||||
val searchActive = filter.searchQuery.isNotBlank()
|
val searchActive = filter.searchQuery.isNotBlank()
|
||||||
if (isFiltered || searchActive) {
|
if (isFiltered || searchActive) {
|
||||||
ActiveFilterChips(
|
ActiveFilterChips(
|
||||||
@@ -172,14 +173,84 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────────────────
|
// ── Content ────────────────────────────────────────────────────
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val s = state) {
|
|
||||||
|
|
||||||
is HistoryState.Loading -> {
|
// ── List — always rendered when state is Success, never unmounted ──────
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
val s = state
|
||||||
|
if (s is HistoryState.Success) {
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible != null && lastVisible.index >= totalItems - 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = strings.history.historyNoPayments,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = s.isRefreshing,
|
||||||
|
onRefresh = { vm.refresh() }
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = items,
|
||||||
|
key = { it.paymentHash },
|
||||||
|
contentType = { "payment" }
|
||||||
|
) { item ->
|
||||||
|
PaymentRow(
|
||||||
|
item = item,
|
||||||
|
onClick = { onPaymentClick(item) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.canLoadMore) {
|
||||||
|
item(contentType = "loader") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State overlays — sit on top of the list, never destroy it ─────────
|
||||||
|
when (val s = state) {
|
||||||
|
is HistoryState.Loading -> {
|
||||||
|
// Only show the full-screen spinner on the very first load
|
||||||
|
// (when we have no items yet). During loadMore(), the in-list
|
||||||
|
// footer spinner handles it — don't obscure the results.
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
is HistoryState.Error -> {
|
is HistoryState.Error -> {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
@@ -192,96 +263,33 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Button(onClick = { vm.refresh() }) {
|
Button(onClick = { vm.refresh() }) {
|
||||||
Text(strings.history.historyRetry) // ← "Retry"
|
Text(strings.history.historyRetry)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is HistoryState.Success -> {
|
|
||||||
val shouldLoadMore by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
|
||||||
val totalItems = listState.layoutInfo.totalItemsCount
|
|
||||||
lastVisible != null && lastVisible.index >= totalItems - 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(shouldLoadMore) {
|
|
||||||
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.payments.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = strings.history.historyNoPayments, // ← "No payments yet."
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
PullToRefreshBox(
|
|
||||||
isRefreshing = s.isRefreshing,
|
|
||||||
onRefresh = { vm.refresh() }
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
items(
|
|
||||||
items = s.payments,
|
|
||||||
key = { it.paymentHash },
|
|
||||||
contentType = { "payment" }
|
|
||||||
) { payment ->
|
|
||||||
PaymentRow(
|
|
||||||
payment = payment,
|
|
||||||
fiatCurrency = fiatCurrency,
|
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
|
||||||
contactName = contactNames[payment.checkingId],
|
|
||||||
onClick = { onPaymentClick(payment) }
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
thickness = 0.5.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (s.canLoadMore) {
|
|
||||||
item(contentType = "loader") {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is HistoryState.Success -> { /* handled above */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
|
|
||||||
|
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
|
||||||
if (filterSheetVisible) {
|
if (filterSheetVisible) {
|
||||||
FilterBottomSheet(
|
FilterBottomSheet(
|
||||||
currentFilter = filter,
|
currentFilter = filter,
|
||||||
availableTypes = availableTypes,
|
availableTypes = availableTypes,
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
onDismiss = dismissFilterSheet,
|
onDismiss = dismissFilterSheet,
|
||||||
onDirectionSelected = { direction ->
|
onDirectionSelected = { direction ->
|
||||||
vm.setDirectionFilter(direction)
|
vm.setDirectionFilter(direction)
|
||||||
dismissFilterSheet()
|
dismissFilterSheet()
|
||||||
},
|
},
|
||||||
onStatusToggled = { vm.toggleStatusFilter(it) },
|
onStatusToggled = { vm.toggleStatusFilter(it) },
|
||||||
onTypeToggled = { vm.toggleTypeFilter(it) },
|
onTypeToggled = { vm.toggleTypeFilter(it) },
|
||||||
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
|
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
|
||||||
onDatePresetSelected = { vm.applyDatePreset(it) },
|
onDatePresetSelected = { vm.applyDatePreset(it) },
|
||||||
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
|
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
|
||||||
onDateClearRequested = { vm.clearDateFilter() }
|
onDateClearRequested = { vm.clearDateFilter() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -291,7 +299,7 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Search bar ───────────────────────────────────────────────────────────────
|
// ── Search bar ────────────────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchBar(
|
private fun SearchBar(
|
||||||
query : String,
|
query : String,
|
||||||
@@ -312,13 +320,13 @@ private fun SearchBar(
|
|||||||
IconButton(onClick = onInfoClick) {
|
IconButton(onClick = onInfoClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Info,
|
imageVector = Icons.Default.Info,
|
||||||
contentDescription = strings.history.historySearchHelp // ← "Search help"
|
contentDescription = strings.history.historySearchHelp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
|
placeholder = { Text(strings.history.historySearchPlaceholder) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
@@ -339,7 +347,7 @@ private fun SearchBar(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ContentPaste,
|
imageVector = Icons.Default.ContentPaste,
|
||||||
contentDescription = strings.history.historySearchPaste // ← "Paste"
|
contentDescription = strings.history.historySearchPaste
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -348,7 +356,7 @@ private fun SearchBar(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.historySearchClear, // ← "Clear search"
|
contentDescription = strings.history.historySearchClear,
|
||||||
tint = if (query.isNotEmpty()) LocalContentColor.current
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
)
|
)
|
||||||
@@ -356,22 +364,22 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active filter chips ──────────────────────────────────────────────────────
|
// ── Active filter chips ───────────────────────────────────────────────────────
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveFilterChips(
|
private fun ActiveFilterChips(
|
||||||
filter : PaymentFilter,
|
filter : PaymentFilter,
|
||||||
searchActive : Boolean,
|
searchActive : Boolean,
|
||||||
onClearDirection: () -> Unit,
|
onClearDirection : () -> Unit,
|
||||||
onClearStatus : (StatusFilter) -> Unit,
|
onClearStatus : (StatusFilter) -> Unit,
|
||||||
onClearType : (String) -> Unit,
|
onClearType : (String) -> Unit,
|
||||||
onClearAmount : () -> Unit,
|
onClearAmount : () -> Unit,
|
||||||
onClearDate : () -> Unit,
|
onClearDate : () -> Unit,
|
||||||
onClearSearch : () -> Unit
|
onClearSearch : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -384,15 +392,15 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (filter.direction) {
|
when (filter.direction) {
|
||||||
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
|
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
|
||||||
else -> strings.history.filterDirectionIncoming // ← "Incoming"
|
else -> strings.history.filterDirectionIncoming
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
|
contentDescription = strings.history.filterClearDirection,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -405,16 +413,16 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (s) {
|
when (s) {
|
||||||
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
|
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
|
||||||
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
|
StatusFilter.PENDING -> strings.history.filterStatusPending
|
||||||
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
|
StatusFilter.FAILED -> strings.history.filterStatusFailed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
|
contentDescription = strings.history.filterRemoveStatus,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -428,7 +436,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.filterRemoveType, // ← "Remove type filter"
|
contentDescription = strings.history.filterRemoveType,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -437,11 +445,11 @@ private fun ActiveFilterChips(
|
|||||||
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||||
val label = when {
|
val label = when {
|
||||||
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||||
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats"
|
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
|
||||||
filter.minAmountSat != null ->
|
filter.minAmountSat != null ->
|
||||||
strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
|
strings.history.filterAmountMin(filter.minAmountSat)
|
||||||
else ->
|
else ->
|
||||||
strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
|
strings.history.filterAmountMax(filter.maxAmountSat!!)
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
@@ -450,7 +458,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter"
|
contentDescription = strings.history.filterClearAmount,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -460,19 +468,23 @@ private fun ActiveFilterChips(
|
|||||||
val maxCreatedAt = filter.maxCreatedAt
|
val maxCreatedAt = filter.maxCreatedAt
|
||||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||||
val label = when (filter.datePreset) {
|
val label = when (filter.datePreset) {
|
||||||
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
|
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
|
||||||
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
|
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
|
||||||
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
|
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
|
||||||
null -> when {
|
null -> when {
|
||||||
minCreatedAt != null && maxCreatedAt != null ->
|
minCreatedAt != null && maxCreatedAt != null ->
|
||||||
strings.history.filterDateRange( // ← "X–Y"
|
strings.history.filterDateRange(
|
||||||
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
|
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
|
||||||
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
|
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
|
||||||
)
|
)
|
||||||
minCreatedAt != null ->
|
minCreatedAt != null ->
|
||||||
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
|
strings.history.filterDateFrom(
|
||||||
|
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
else ->
|
else ->
|
||||||
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
|
strings.history.filterDateUntil(
|
||||||
|
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -482,7 +494,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.filterClearDate, // ← "Clear date filter"
|
contentDescription = strings.history.filterClearDate,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -492,11 +504,11 @@ private fun ActiveFilterChips(
|
|||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
onClick = onClearSearch,
|
onClick = onClearSearch,
|
||||||
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
|
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.history.historyClearSearch, // ← "Clear search"
|
contentDescription = strings.history.historyClearSearch,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,118 +8,265 @@ import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|||||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
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.PaymentFeedRepository
|
||||||
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.ContactEntity
|
||||||
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
|
||||||
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
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "HistoryViewModel"
|
private const val TAG = "HistoryViewModel"
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class HistoryViewModel(
|
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,
|
private val contactRepo : ContactRepository,
|
||||||
val fiatCurrency : StateFlow<String?>,
|
private val feedRepository : PaymentFeedRepository,
|
||||||
val fiatSatsPerUnit : StateFlow<Double?>
|
val fiatCurrency : StateFlow<String?>,
|
||||||
|
val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
|
|
||||||
|
// ── Sync manager ──────────────────────────────────────────────────────────
|
||||||
|
// Owns the websocket/sync loop, pagination, and optimistic inserts.
|
||||||
|
// Still operates on PaymentRecord internally — untouched by this refactor.
|
||||||
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
onNewPayments = { payments -> enricher.enrich(payments) }
|
onNewPayments = { payments -> enricher.enrich(payments) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Optimistic insert (called after a successful send) ─────────────────────
|
// ── Optimistic insert (called after a successful send) ─────────────────────
|
||||||
fun primePayment(record: PaymentRecord) {
|
fun primePayment(record: PaymentRecord) {
|
||||||
syncManager.primePayment(record)
|
syncManager.primePayment(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Enricher ──────────────────────────────────────────────────────────────
|
||||||
|
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
|
||||||
|
// which feeds into the feedRepository.observeFeedItems() combine.
|
||||||
private val enricher: PaymentEnricher = PaymentEnricher(
|
private val enricher: PaymentEnricher = PaymentEnricher(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
onEnriched = { paymentHash, pubkey, alias ->
|
onEnriched = { paymentHash, pubkey, alias ->
|
||||||
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
|
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Sync status ───────────────────────────────────────────────────────────
|
||||||
|
// Exposes Loading / Error / Success shell (isRefreshing, canLoadMore).
|
||||||
|
// Does NOT carry payment data — see feedItems below.
|
||||||
val state: StateFlow<HistoryState> = syncManager.state
|
val state: StateFlow<HistoryState> = syncManager.state
|
||||||
fun refresh() = syncManager.refresh()
|
|
||||||
|
fun refresh() = syncManager.refresh()
|
||||||
fun loadMore() = syncManager.loadMore(
|
fun loadMore() = syncManager.loadMore(
|
||||||
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true }
|
filteredCanLoadMore = {
|
||||||
|
(state.value as? HistoryState.Success)?.canLoadMore == true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Detail delegate ───────────────────────────────────────────────────────
|
// ── Detail delegate ────────────────────────────────────────────────────────
|
||||||
private val detailDelegate = PaymentDetailDelegate(
|
private val detailDelegate = PaymentDetailDelegate(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
scope = viewModelScope
|
scope = viewModelScope
|
||||||
)
|
)
|
||||||
val detailState: StateFlow<DetailState> = detailDelegate.state
|
val detailState: StateFlow<DetailState> = detailDelegate.state
|
||||||
fun loadDetail(checkingId: String, record: PaymentRecord? = null) =
|
fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
|
||||||
detailDelegate.load(checkingId, record)
|
detailDelegate.load(paymentHash, record)
|
||||||
|
|
||||||
fun clearDetail() = detailDelegate.clear()
|
fun clearDetail() = detailDelegate.clear()
|
||||||
|
|
||||||
// ── Filter state ──────────────────────────────────────────────────────────
|
// ── Filter state ───────────────────────────────────────────────────────────
|
||||||
private val _filter = MutableStateFlow(PaymentFilter())
|
private val _filter = MutableStateFlow(PaymentFilter())
|
||||||
val filter: StateFlow<PaymentFilter> = _filter
|
val filter: StateFlow<PaymentFilter> = _filter
|
||||||
|
|
||||||
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
|
fun setSearchQuery(q: String) {
|
||||||
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
|
_filter.update { it.copy(searchQuery = q.trim()) }
|
||||||
|
|
||||||
fun setDirectionFilter(direction: DirectionFilter) {
|
|
||||||
_filter.update { it.copy(direction = direction) }
|
|
||||||
}
|
}
|
||||||
fun toggleStatusFilter(status: StatusFilter) {
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_filter.update { it.copy(searchQuery = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDirectionFilter(d: DirectionFilter) {
|
||||||
|
_filter.update { it.copy(direction = d) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleStatusFilter(s: StatusFilter) {
|
||||||
_filter.update { f ->
|
_filter.update { f ->
|
||||||
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
|
val updated = if (s in f.statuses) f.statuses - s else f.statuses + s
|
||||||
f.copy(statuses = updated)
|
f.copy(statuses = updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleTypeFilter(type: String) {
|
fun toggleTypeFilter(type: String) {
|
||||||
_filter.update { f ->
|
_filter.update { f ->
|
||||||
val updated = if (type in f.types) f.types - type else f.types + type
|
val updated = if (type in f.types) f.types - type else f.types + type
|
||||||
f.copy(types = updated)
|
f.copy(types = updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAmountFilter(min: Long?, max: Long?) {
|
fun setAmountFilter(min: Long?, max: Long?) {
|
||||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyDatePreset(preset: DatePreset) {
|
fun applyDatePreset(preset: DatePreset) {
|
||||||
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt
|
val (min, max) = preset.toEpochSecondRange()
|
||||||
setDateFilter(min, max, preset)
|
setDateFilter(min, max, preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearDateFilter() {
|
fun clearDateFilter() {
|
||||||
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findPayment(checkingId: String): PaymentRecord? {
|
fun toggleContactFilter(contactId: String) {
|
||||||
val fromFiltered = (filteredState.value as? HistoryState.Success)
|
_filter.update { f ->
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
val updated = if (contactId in f.contactIds)
|
||||||
if (fromFiltered != null) return fromFiltered
|
f.contactIds - contactId else f.contactIds + contactId
|
||||||
return (state.value as? HistoryState.Success)
|
f.copy(contactIds = updated)
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps contactId → display name (for filter chips)
|
fun clearContactFilter() {
|
||||||
|
_filter.update { it.copy(contactIds = emptySet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Room-backed query results ──────────────────────────────────────────────
|
||||||
|
// Null = use the live feed stream.
|
||||||
|
// Non-null = a search / contact-ID Room query has run and its results
|
||||||
|
// override the live stream until the filter is cleared.
|
||||||
|
private val _roomResults = MutableStateFlow<List<PaymentFeedItem>?>(null)
|
||||||
|
|
||||||
|
// ── Feed items — single source of truth for the UI list ───────────────────
|
||||||
|
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
|
||||||
|
// source changes: payments DB, contact links, alias cache, fiat rate,
|
||||||
|
// active filter, or Room query override.
|
||||||
|
val feedItems: StateFlow<List<PaymentFeedItem>> = combine(
|
||||||
|
feedRepository.observeFeedItems(),
|
||||||
|
_filter,
|
||||||
|
_roomResults
|
||||||
|
) { feed, f, roomResults ->
|
||||||
|
Triple(feed, f, roomResults)
|
||||||
|
}
|
||||||
|
.distinctUntilChanged { old, new ->
|
||||||
|
// If room results are active on both sides and haven't changed, suppress
|
||||||
|
val oldRoom = old.third
|
||||||
|
val newRoom = new.third
|
||||||
|
if (oldRoom != null && newRoom != null && oldRoom === newRoom && old.second == new.second) {
|
||||||
|
return@distinctUntilChanged true // same room results, same filter — skip
|
||||||
|
}
|
||||||
|
false // otherwise let through
|
||||||
|
}
|
||||||
|
.map { (feed, f, roomResults) ->
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
roomResults?.applyFeedFilters(
|
||||||
|
f.copy(searchQuery = "", contactIds = emptySet())
|
||||||
|
) ?: feed.applyFeedFilters(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
// ── Available type filter chips ────────────────────────────────────────────
|
||||||
|
val availableTypes: StateFlow<List<String>> = feedItems
|
||||||
|
.map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
// ── Find a single item by paymentHash ─────────────────────────────────────
|
||||||
|
// paymentHash supersedes checkingId as the stable unique identifier.
|
||||||
|
// Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip.
|
||||||
|
fun findPayment(paymentHash: String): PaymentFeedItem? =
|
||||||
|
feedItems.value.firstOrNull { it.paymentHash == paymentHash }
|
||||||
|
|
||||||
|
// ── In-memory filter logic ─────────────────────────────────────────────────
|
||||||
|
// All filter keys map directly to PaymentFeedItem fields — no extra
|
||||||
|
// resolution needed (contact names, alias, fiat are already embedded).
|
||||||
|
private fun List<PaymentFeedItem>.applyInMemoryFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||||
|
distinctBy { it.paymentHash }
|
||||||
|
.let { list ->
|
||||||
|
when (f.direction) {
|
||||||
|
DirectionFilter.ALL -> list
|
||||||
|
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||||
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.PENDING -> item.isPending
|
||||||
|
StatusFilter.FAILED -> item.isFailed
|
||||||
|
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.types.isEmpty()) list
|
||||||
|
else list.filter { it.tag in f.types }
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val min = f.minAmountSat;
|
||||||
|
val max = f.maxAmountSat
|
||||||
|
if (min == null && max == null) list
|
||||||
|
else list.filter { item ->
|
||||||
|
(min == null || item.amountSat >= min) &&
|
||||||
|
(max == null || item.amountSat <= max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val min = f.minCreatedAt;
|
||||||
|
val max = f.maxCreatedAt
|
||||||
|
if (min == null && max == null) list
|
||||||
|
else list.filter { item ->
|
||||||
|
val ts = item.createdAt ?: item.time
|
||||||
|
ts != null &&
|
||||||
|
(min == null || ts >= min) &&
|
||||||
|
(max == null || ts <= max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val q = f.searchQuery
|
||||||
|
if (q.isBlank()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
item.memo?.contains(q, ignoreCase = true) == true ||
|
||||||
|
item.contactName?.contains(q, ignoreCase = true) == true ||
|
||||||
|
item.paymentHash.contains(q, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact display names (for filter chips) ───────────────────────────────
|
||||||
|
// Used to render contact names in active filter chips on the UI.
|
||||||
|
// Payment row contact names are embedded in PaymentFeedItem.contactName.
|
||||||
val contactDisplayNames: StateFlow<Map<String, String>> =
|
val contactDisplayNames: StateFlow<Map<String, String>> =
|
||||||
contactRepo.observeAllContacts()
|
contactRepo.observeAllContacts()
|
||||||
.map { contacts ->
|
.map { contacts ->
|
||||||
@@ -136,56 +283,7 @@ class HistoryViewModel(
|
|||||||
initialValue = emptyMap()
|
initialValue = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Room-backed query results ──────────────────────────────────────────────
|
// ── Per-payment contact queries ────────────────────────────────────────────
|
||||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
|
||||||
|
|
||||||
// ── Derived state ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Distinct non-null tags present in the currently loaded list. */
|
|
||||||
val availableTypes: StateFlow<List<String>> = syncManager.state
|
|
||||||
.map { s ->
|
|
||||||
if (s !is HistoryState.Success) emptyList()
|
|
||||||
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
|
|
||||||
}
|
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
|
||||||
|
|
||||||
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults ->
|
|
||||||
if (s !is HistoryState.Success) return@combine s
|
|
||||||
if (roomResults == null) {
|
|
||||||
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
|
||||||
} else {
|
|
||||||
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
|
||||||
}
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
|
||||||
|
|
||||||
// ── In-memory filter logic ────────────────────────────────────────────────
|
|
||||||
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
|
||||||
distinctBy { it.paymentHash }
|
|
||||||
.let { list ->
|
|
||||||
when (f.direction) {
|
|
||||||
DirectionFilter.ALL -> list
|
|
||||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
|
||||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.let { list ->
|
|
||||||
if (f.types.isEmpty()) list
|
|
||||||
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>> =
|
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
|
||||||
contactRepo.observeContactsForPayment(paymentHash)
|
contactRepo.observeContactsForPayment(paymentHash)
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -202,142 +300,169 @@ class HistoryViewModel(
|
|||||||
initialValue = emptyList()
|
initialValue = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Contact assignment ─────────────────────────────────────────────────────
|
||||||
fun assignContact(paymentHash: String, contactId: String) {
|
fun assignContact(paymentHash: String, contactId: String) {
|
||||||
viewModelScope.launch {
|
Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unassignContact(paymentHash: String, contactId: String) {
|
fun unassignContact(paymentHash: String, contactId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) }
|
||||||
contactRepo.unlinkPaymentFromContact(paymentHash, contactId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveContactAndAssign(
|
fun saveContactAndAssign(
|
||||||
paymentHash : String,
|
paymentHash: String,
|
||||||
name : String,
|
name: String,
|
||||||
addressType : PaymentAddressType?,
|
addressType: PaymentAddressType?,
|
||||||
address : String?
|
address: String?
|
||||||
) {
|
) {
|
||||||
|
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
val contact = contactRepo.createContact(
|
val contact = contactRepo.createContact(
|
||||||
localAlias = name,
|
localAlias = name,
|
||||||
lnAddress = lnAddress,
|
lnAddress = lnAddress,
|
||||||
lnUrl = lnUrl
|
lnUrl = lnUrl
|
||||||
)
|
)
|
||||||
|
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
|
||||||
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleContactFilter(contactId: String) {
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
_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 {
|
||||||
syncManager.init()
|
syncManager.init()
|
||||||
|
|
||||||
|
// ── Room query path ────────────────────────────────────────────────────
|
||||||
|
// Activated when the filter requires DB-level text search or contact-ID
|
||||||
|
// lookup. Results are stored in _roomResults and override the live feed
|
||||||
|
// stream in feedItems. Cleared back to null when no Room query is needed.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
_filter.map { it.searchQuery }.debounce(300.milliseconds),
|
_filter.map { it.searchQuery }.debounce(300.milliseconds),
|
||||||
_filter
|
_filter
|
||||||
) { _, f -> f }
|
) { _, f -> f }
|
||||||
|
.distinctUntilChanged()
|
||||||
.collect { f ->
|
.collect { f ->
|
||||||
if (!f.needsRoomQuery()) {
|
if (!f.needsRoomQuery()) {
|
||||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
|
||||||
_roomResults.value = null
|
_roomResults.value = null
|
||||||
} else {
|
return@collect
|
||||||
// ── Room query path ───────────────────────────────────
|
}
|
||||||
val baseResults = paymentCache.queryAll(f.searchQuery, f)
|
|
||||||
.toMutableList()
|
|
||||||
|
|
||||||
// ── Supplemental contact search ───────────────────────
|
// Snapshot the contact summaries once per query — no new DAO needed.
|
||||||
// 1. Text query may match contact names not in payment_records
|
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
|
||||||
if (f.searchQuery.isNotBlank()) {
|
// resolve contact names without per-row DB calls.
|
||||||
val contactCheckingIds = contactRepo
|
val contactSnapshot: Map<String, List<ContactSummary>> =
|
||||||
.findCheckingIdsByContactName(f.searchQuery)
|
contactRepo.observePaymentContactSummaries()
|
||||||
if (contactCheckingIds.isNotEmpty()) {
|
.first()
|
||||||
val contactPayments = paymentCache
|
.groupBy(
|
||||||
.queryByCheckingIds(contactCheckingIds)
|
keySelector = { it.paymentHash },
|
||||||
// Merge without duplicates
|
valueTransform = {
|
||||||
val existingIds = baseResults.map { it.checkingId }.toSet()
|
ContactSummary(
|
||||||
baseResults += contactPayments
|
id = it.contactId,
|
||||||
.filter { it.checkingId !in existingIds }
|
displayName = it.displayName
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
// ── Explicit contact-ID filter (highest priority) ──────────
|
||||||
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results (${it.size - (paymentCache.queryAll(f.searchQuery, f).size)} from contacts)")
|
if (f.contactIds.isNotEmpty()) {
|
||||||
|
val contactHashes = contactRepo
|
||||||
|
.findPaymentHashesByContactIds(f.contactIds.toList())
|
||||||
|
_roomResults.value = if (contactHashes.isNotEmpty()) {
|
||||||
|
paymentCache.queryByPaymentHashes(contactHashes, f)
|
||||||
|
.map { it.toFeedItem(contactSnapshot) }
|
||||||
|
.also {
|
||||||
|
Timber.tag(TAG).d(
|
||||||
|
"FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text search ────────────────────────────────────────────
|
||||||
|
val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList()
|
||||||
|
|
||||||
|
if (f.searchQuery.isNotBlank()) {
|
||||||
|
val contactHashes = contactRepo
|
||||||
|
.findPaymentHashesByContactName(f.searchQuery)
|
||||||
|
if (contactHashes.isNotEmpty()) {
|
||||||
|
val existingIds = baseRecords.map { it.checkingId }.toSet()
|
||||||
|
baseRecords += paymentCache
|
||||||
|
.queryByPaymentHashes(contactHashes, f)
|
||||||
|
.filter { it.checkingId !in existingIds }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_roomResults.value = baseRecords
|
||||||
|
.map { it.toFeedItem(contactSnapshot) }
|
||||||
|
.also {
|
||||||
|
Timber.tag(TAG).d(
|
||||||
|
"FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── WebSocket payment event listener ───────────────────────────────────
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
WalletNotificationService.paymentEvents.collect { event ->
|
WalletNotificationService.paymentEvents.collect { event ->
|
||||||
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
|
||||||
syncManager.refresh()
|
syncManager.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Private helper ─────────────────────────────────────────────────────────
|
||||||
|
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
|
||||||
|
// Contact links, alias, and fiat are intentionally left empty/null here —
|
||||||
|
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
|
||||||
|
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
|
||||||
|
// for all items that pass through the live feed path.
|
||||||
|
private fun PaymentRecord.toFeedItem(
|
||||||
|
contactSnapshot: Map<String, List<ContactSummary>> = emptyMap()
|
||||||
|
): PaymentFeedItem =
|
||||||
|
this.toPaymentFeedItem(
|
||||||
|
linkedContacts = contactSnapshot[paymentHash] ?: emptyList(),
|
||||||
|
resolvedAlias = pubkey?.let { aliasRepo.aliases.value[it] },
|
||||||
|
fiatCurrency = fiatCurrency.value,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
// ── Factory ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class HistoryViewModelFactory(
|
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 contactRepo : ContactRepository,
|
||||||
private val fiatCurrency : StateFlow<String?>,
|
private val feedRepository : PaymentFeedRepository,
|
||||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
|
private val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
|
||||||
|
"Unknown ViewModel class: ${modelClass.name}"
|
||||||
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return HistoryViewModel(
|
return HistoryViewModel(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
contactRepo = contactRepo,
|
contactRepo = contactRepo,
|
||||||
|
feedRepository = feedRepository,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
) as T
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-8
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
|
|||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
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.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
|
|||||||
class PaymentDetailDelegate(
|
class PaymentDetailDelegate(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val scope : CoroutineScope
|
private val scope : CoroutineScope
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
|
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||||
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
|
|||||||
|
|
||||||
fun load(checkingId: String, record: PaymentRecord? = null) {
|
fun load(checkingId: String, record: PaymentRecord? = null) {
|
||||||
paymentCache.getCachedDetail(checkingId)?.let {
|
paymentCache.getCachedDetail(checkingId)?.let {
|
||||||
Timber.d("DETAIL [HIT] $checkingId — instant from cache")
|
Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache")
|
||||||
_state.value = DetailState.Success(it)
|
_state.value = DetailState.Success(it)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.value = if (record != null) {
|
_state.value = if (record != null) {
|
||||||
Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
||||||
DetailState.Partial(record)
|
DetailState.Partial(record)
|
||||||
} else {
|
} else {
|
||||||
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
||||||
DetailState.Loading
|
DetailState.Loading
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
||||||
if (fromDb != null) {
|
if (fromDb != null) {
|
||||||
paymentCache.saveDetail(checkingId, fromDb)
|
val record = fromDb.details
|
||||||
_state.value = DetailState.Success(fromDb)
|
val enriched = if (
|
||||||
Timber.d("DETAIL [DB] $checkingId — served from Room")
|
record != null &&
|
||||||
|
record.pubkey.isNullOrBlank() &&
|
||||||
|
!record.bolt11.isNullOrBlank()
|
||||||
|
) {
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey")
|
||||||
|
val pubkey = runCatching {
|
||||||
|
repo.decodeBolt11(record.bolt11!!).payee
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (pubkey != null) {
|
||||||
|
val alias = runCatching {
|
||||||
|
aliasRepo.resolve(pubkey)
|
||||||
|
}.getOrNull()
|
||||||
|
paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias)
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias")
|
||||||
|
fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias))
|
||||||
|
} else {
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed")
|
||||||
|
fromDb
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fromDb
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentCache.saveDetail(checkingId, enriched)
|
||||||
|
_state.value = DetailState.Success(enriched)
|
||||||
|
Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
runCatching { repo.getPaymentDetail(checkingId) }
|
runCatching { repo.getPaymentDetail(checkingId) }
|
||||||
.onSuccess { detail ->
|
.onSuccess { detail ->
|
||||||
Timber.d("DETAIL [NET] $checkingId — fetched from network")
|
Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
|
||||||
paymentCache.saveDetail(checkingId, detail)
|
paymentCache.saveDetail(checkingId, detail)
|
||||||
_state.value = DetailState.Success(detail)
|
_state.value = DetailState.Success(detail)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Timber.w("DETAIL [ERR] $checkingId — ${e.message}")
|
Timber.tag(TAG).w("DETAIL [ERR] $checkingId — ${e.message}")
|
||||||
if (_state.value !is DetailState.Partial) {
|
if (_state.value !is DetailState.Partial) {
|
||||||
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
|
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() { _state.value = DetailState.Idle }
|
fun clear() { _state.value = DetailState.Idle }
|
||||||
}
|
}
|
||||||
+154
-113
@@ -20,6 +20,7 @@ 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.Close
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
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
|
||||||
@@ -53,8 +54,11 @@ 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.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
|
||||||
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.ContactAvatar
|
||||||
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
|
||||||
@@ -66,41 +70,57 @@ 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
|
||||||
fun PaymentDetailScreen(
|
fun PaymentDetailScreen(
|
||||||
payment : PaymentRecord,
|
item : PaymentFeedItem, // ← was: payment: PaymentRecord
|
||||||
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 fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
// fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
|
||||||
|
// no need to collect them from the ViewModel separately.
|
||||||
|
val fiatCurrency = item.fiatCurrency
|
||||||
|
val fiatSatsPerUnit = item.fiatSatsPerUnit
|
||||||
|
|
||||||
|
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
|
||||||
|
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
|
||||||
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||||
|
|
||||||
|
// Enrich the base PaymentFeedItem's backing record with the detail response.
|
||||||
|
// PaymentDetailContent still operates on PaymentRecord for technical fields.
|
||||||
|
// enriched falls back through partial → base PaymentRecord via toDomain()
|
||||||
|
// if the detail fetch is still in flight or has failed.
|
||||||
val enriched: PaymentRecord = when (val s = detailState) {
|
val enriched: PaymentRecord = when (val s = detailState) {
|
||||||
is DetailState.Success -> {
|
is DetailState.Success -> {
|
||||||
val detail = s.detail.details
|
val detail = s.detail.details
|
||||||
when {
|
when {
|
||||||
detail == null -> payment
|
detail == null -> item.toBaseRecord()
|
||||||
else -> detail.copy(
|
else -> detail.copy(
|
||||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
|
||||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
|
||||||
|
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||||
|
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DetailState.Partial -> s.record
|
is DetailState.Partial -> s.record.let { r ->
|
||||||
else -> payment
|
r.copy(
|
||||||
|
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||||
|
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> item.toBaseRecord()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contact state ─────────────────────────────────────────────────────────
|
|
||||||
val linkedContacts by remember(payment.checkingId) {
|
// ── Contact state ──────────────────────────────────────────────────────────
|
||||||
vm.contactsForPayment(payment.checkingId)
|
val linkedContacts by remember(item.paymentHash) {
|
||||||
|
vm.contactsForPayment(item.paymentHash)
|
||||||
}.collectAsStateWithLifecycle()
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
||||||
@@ -108,8 +128,7 @@ fun PaymentDetailScreen(
|
|||||||
var showContactPicker by remember { mutableStateOf(false) }
|
var showContactPicker by remember { mutableStateOf(false) }
|
||||||
var showCreateContact by remember { mutableStateOf(false) }
|
var showCreateContact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Best-effort lnAddress to pre-fill CreateContactSheet:
|
// Best-effort lnAddress to pre-fill CreateContactSheet
|
||||||
// prefer extra.comment if it looks like a Lightning Address, else null
|
|
||||||
val paymentLnAddress = remember(enriched) {
|
val paymentLnAddress = remember(enriched) {
|
||||||
enriched.extra?.comment
|
enriched.extra?.comment
|
||||||
?.takeIf { it.contains("@") && !it.contains(" ") }
|
?.takeIf { it.contains("@") && !it.contains(" ") }
|
||||||
@@ -120,7 +139,7 @@ fun PaymentDetailScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
if (payment.isOutgoing) strings.history.detailOutgoingTitle
|
if (item.isOutgoing) strings.history.detailOutgoingTitle
|
||||||
else strings.history.detailIncomingTitle
|
else strings.history.detailIncomingTitle
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -138,8 +157,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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +182,7 @@ fun PaymentDetailScreen(
|
|||||||
linkedContacts = linkedContacts,
|
linkedContacts = linkedContacts,
|
||||||
onAssignContact = { showContactPicker = true },
|
onAssignContact = { showContactPicker = true },
|
||||||
onUnassignContact = { contactId ->
|
onUnassignContact = { contactId ->
|
||||||
vm.unassignContact(payment.checkingId, contactId)
|
vm.unassignContact(item.paymentHash, contactId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,7 +196,7 @@ fun PaymentDetailScreen(
|
|||||||
linkedContacts = linkedContacts,
|
linkedContacts = linkedContacts,
|
||||||
onAssignContact = { showContactPicker = true },
|
onAssignContact = { showContactPicker = true },
|
||||||
onUnassignContact = { contactId ->
|
onUnassignContact = { contactId ->
|
||||||
vm.unassignContact(payment.checkingId, contactId)
|
vm.unassignContact(item.paymentHash, contactId)
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(padding)
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
@@ -185,12 +204,12 @@ fun PaymentDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contact picker sheet ──────────────────────────────────────────────────
|
// ── Contact picker sheet ───────────────────────────────────────────────────
|
||||||
if (showContactPicker) {
|
if (showContactPicker) {
|
||||||
ContactPickerSheet(
|
ContactPickerSheet(
|
||||||
contacts = allContacts,
|
contacts = allContacts,
|
||||||
onContactSelected = { contactId ->
|
onContactSelected = { contactId ->
|
||||||
vm.assignContact(payment.checkingId, contactId)
|
vm.assignContact(item.paymentHash, contactId)
|
||||||
showContactPicker = false
|
showContactPicker = false
|
||||||
},
|
},
|
||||||
onCreateNew = {
|
onCreateNew = {
|
||||||
@@ -201,7 +220,7 @@ fun PaymentDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create & assign contact sheet ─────────────────────────────────────────
|
// ── Create & assign contact sheet ──────────────────────────────────────────
|
||||||
if (showCreateContact) {
|
if (showCreateContact) {
|
||||||
val addressType = if (paymentLnAddress != null)
|
val addressType = if (paymentLnAddress != null)
|
||||||
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
@@ -209,8 +228,8 @@ fun PaymentDetailScreen(
|
|||||||
CreateContactSheet(
|
CreateContactSheet(
|
||||||
initialAddressType = addressType,
|
initialAddressType = addressType,
|
||||||
initialAddress = paymentLnAddress,
|
initialAddress = paymentLnAddress,
|
||||||
onSave = { name, type, address ->
|
onSave = { name, type, address ->
|
||||||
vm.saveContactAndAssign(payment.checkingId, name, type, address)
|
vm.saveContactAndAssign(item.paymentHash, name, type, address)
|
||||||
showCreateContact = false
|
showCreateContact = false
|
||||||
},
|
},
|
||||||
onDismiss = { showCreateContact = false }
|
onDismiss = { showCreateContact = false }
|
||||||
@@ -219,19 +238,26 @@ fun PaymentDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail content ─────────────────────────────────────────────────────────────
|
// ── Detail content ─────────────────────────────────────────────────────────────
|
||||||
|
// Receives the enriched PaymentRecord from detailState — all technical fields
|
||||||
|
// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here.
|
||||||
|
// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item).
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaymentDetailContent(
|
private fun PaymentDetailContent(
|
||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||||
onAssignContact : () -> Unit,
|
onAssignContact : () -> Unit,
|
||||||
onUnassignContact : (contactId: String) -> Unit,
|
onUnassignContact : (contactId: String) -> Unit,
|
||||||
modifier : Modifier = Modifier
|
modifier : Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
val amountColor = paymentAmountColor(
|
||||||
|
isPending = payment.pending,
|
||||||
|
isFailed = payment.status == "failed",
|
||||||
|
isOutgoing = payment.isOutgoing
|
||||||
|
)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
@@ -252,8 +278,8 @@ 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)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -261,8 +287,8 @@ private fun PaymentDetailContent(
|
|||||||
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
|
||||||
@@ -273,11 +299,11 @@ private fun PaymentDetailContent(
|
|||||||
)
|
)
|
||||||
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) {
|
||||||
@@ -303,70 +329,85 @@ private fun PaymentDetailContent(
|
|||||||
|
|
||||||
// ── Contact section ────────────────────────────────────────────────────
|
// ── Contact section ────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = "Contact") {
|
if (linkedContacts.isEmpty()) {
|
||||||
if (linkedContacts.isEmpty()) {
|
OutlinedButton(
|
||||||
// No contact linked — show assign button
|
onClick = onAssignContact,
|
||||||
OutlinedButton(
|
modifier = Modifier.fillMaxWidth()
|
||||||
onClick = onAssignContact,
|
) {
|
||||||
modifier = Modifier
|
Icon(
|
||||||
.fillMaxWidth()
|
imageVector = Icons.Default.PersonAdd,
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
contentDescription = null,
|
||||||
) {
|
modifier = Modifier.size(18.dp)
|
||||||
Icon(
|
)
|
||||||
imageVector = Icons.Default.PersonAdd,
|
Spacer(Modifier.width(6.dp))
|
||||||
contentDescription = null,
|
Text("Assign contact")
|
||||||
modifier = Modifier.size(18.dp)
|
// Text(strings.history.detailAssignContact)
|
||||||
)
|
}
|
||||||
Spacer(Modifier.width(6.dp))
|
} else {
|
||||||
Text("Assign contact")
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
}
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
} else {
|
Row(
|
||||||
// Show each linked contact with a remove button
|
modifier = Modifier.fillMaxWidth(),
|
||||||
linkedContacts.forEach { contact ->
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
val name = contact.localAlias
|
verticalAlignment = Alignment.CenterVertically
|
||||||
?: contact.displayName
|
) {
|
||||||
?: contact.name
|
Text(
|
||||||
?: "Unknown"
|
text = "Contacts",
|
||||||
DetailRow(
|
// text = strings.history.detailContactsTitle,
|
||||||
label = "Contact",
|
style = MaterialTheme.typography.titleSmall,
|
||||||
value = name,
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
trailingContent = {
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onAssignContact,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit",
|
||||||
|
// contentDescription = strings.history.detailEditContacts,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
linkedContacts.forEachIndexed { index, contact ->
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ContactAvatar(name = name)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onUnassignContact(contact.id) },
|
onClick = { onUnassignContact(contact.id) },
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = "Remove contact",
|
contentDescription = "Remove",
|
||||||
|
// contentDescription = strings.history.detailRemoveContact,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
if (index < linkedContacts.lastIndex) {
|
||||||
}
|
HorizontalDivider(
|
||||||
// Allow assigning additional contacts
|
thickness = 0.5.dp,
|
||||||
HorizontalDivider(
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,8 +438,8 @@ private fun PaymentDetailContent(
|
|||||||
payment.extra?.successAction?.let { action ->
|
payment.extra?.successAction?.let { action ->
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||||
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||||
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||||
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,11 +449,11 @@ private fun PaymentDetailContent(
|
|||||||
item {
|
item {
|
||||||
DetailSection(title = strings.history.detailSectionTechnical) {
|
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowPaymentHash,
|
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))
|
||||||
@@ -422,11 +463,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,
|
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))
|
||||||
@@ -439,11 +480,11 @@ 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,
|
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,
|
||||||
onValueClick = {
|
onValueClick = {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
@@ -453,12 +494,12 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
if (!payment.bolt11.isNullOrBlank()) {
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.history.detailRowBolt11,
|
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))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.history
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
@@ -30,6 +31,7 @@ val PaymentFilter.isActive: Boolean
|
|||||||
|| maxAmountSat != null
|
|| maxAmountSat != null
|
||||||
|| minCreatedAt != null
|
|| minCreatedAt != null
|
||||||
|| maxCreatedAt != null
|
|| maxCreatedAt != null
|
||||||
|
|| contactIds.isNotEmpty()
|
||||||
|
|
||||||
fun PaymentFilter.needsRoomQuery(): Boolean =
|
fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||||
searchQuery.isNotBlank() ||
|
searchQuery.isNotBlank() ||
|
||||||
@@ -64,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
|
|||||||
if (f.types.isEmpty()) list
|
if (f.types.isEmpty()) list
|
||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.extra?.tag in f.types }
|
||||||
}
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { record ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.COMPLETED -> !record.pending && record.status == "complete"
|
||||||
|
StatusFilter.PENDING -> record.pending
|
||||||
|
StatusFilter.FAILED -> record.status == "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||||
|
distinctBy { it.paymentHash }
|
||||||
|
.let { list ->
|
||||||
|
when (f.direction) {
|
||||||
|
DirectionFilter.ALL -> list
|
||||||
|
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||||
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.types.isEmpty()) list
|
||||||
|
else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||||
|
StatusFilter.PENDING -> item.isPending
|
||||||
|
StatusFilter.FAILED -> item.isFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.let { list ->
|
||||||
|
if (f.searchQuery.isBlank()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
item.memo?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.contactName?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.alias?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.paymentHash.startsWith(f.searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,34 +22,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
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 com.bitcointxoko.gudariwallet.util.isFailedStatus
|
|
||||||
import com.bitcointxoko.gudariwallet.util.isPendingStatus
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PaymentRow(
|
internal fun PaymentRow(
|
||||||
payment : PaymentRecord,
|
item : PaymentFeedItem,
|
||||||
fiatCurrency : String?,
|
onClick : () -> Unit
|
||||||
fiatSatsPerUnit : Double?,
|
|
||||||
contactName : String? = null,
|
|
||||||
onClick : () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val isPending = isPendingStatus(payment.status)
|
val semantic = semanticColors()
|
||||||
val isFailed = isFailedStatus(payment.status)
|
|
||||||
val semantic = semanticColors()
|
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
|
||||||
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
|
||||||
else Icons.Default.ArrowDownward
|
|
||||||
|
|
||||||
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
val amountColor = paymentAmountColor(
|
||||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
isPending = item.isPending,
|
||||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
isFailed = item.isFailed,
|
||||||
|
isOutgoing = item.isOutgoing
|
||||||
|
)
|
||||||
|
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
|
||||||
|
|
||||||
|
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
|
||||||
|
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
|
||||||
|
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,46 +58,47 @@ internal fun PaymentRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
|
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
|
||||||
else strings.history.paymentRowReceivedCD, // ← "Received"
|
else strings.history.paymentRowReceivedCD,
|
||||||
tint = amountColor,
|
tint = amountColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = contactName // ← contact name first
|
text = item.contactName // contact name first
|
||||||
?: payment.memo?.takeIf { it.isNotBlank() }
|
?: item.memo?.takeIf { it.isNotBlank() } // then memo
|
||||||
?: strings.history.paymentRowNoMemo,
|
?: strings.history.paymentRowNoMemo, // then fallback
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
val formattedTime = remember(item.createdAt ?: item.time) {
|
||||||
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
val statusLabel = when {
|
val statusLabel = when {
|
||||||
isPending -> strings.history.paymentRowPending
|
item.isPending -> strings.history.paymentRowPending
|
||||||
isFailed -> strings.history.paymentRowFailed
|
item.isFailed -> strings.history.paymentRowFailed
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val statusColor = when {
|
val statusColor = when {
|
||||||
isPending -> semantic.warningContainer
|
item.isPending -> semantic.warningContainer
|
||||||
isFailed -> semantic.errorContainer
|
item.isFailed -> semantic.errorContainer
|
||||||
else -> semantic.successContainer // fallback, won't be used
|
else -> semantic.successContainer
|
||||||
}
|
}
|
||||||
val statusContentColor = when {
|
val statusContentColor = when {
|
||||||
isPending -> semantic.onWarningContainer
|
item.isPending -> semantic.onWarningContainer
|
||||||
isFailed -> semantic.onErrorContainer
|
item.isFailed -> semantic.onErrorContainer
|
||||||
else -> semantic.onSuccessContainer
|
else -> semantic.onSuccessContainer
|
||||||
}
|
}
|
||||||
if (statusLabel != null) {
|
if (statusLabel != null) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -119,10 +117,25 @@ internal fun PaymentRow(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
AmountWithFiatColumn(
|
AmountWithFiatColumn(
|
||||||
amountSat = payment.amountSat,
|
amountSat = item.amountSat,
|
||||||
isOutgoing = payment.isOutgoing,
|
isOutgoing = item.isOutgoing,
|
||||||
amountColor = amountColor,
|
amountColor = amountColor,
|
||||||
fiatLine = fiatLine
|
fiatLine = fiatLine
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun paymentAmountColor(
|
||||||
|
isPending : Boolean,
|
||||||
|
isFailed : Boolean,
|
||||||
|
isOutgoing : Boolean
|
||||||
|
): androidx.compose.ui.graphics.Color {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
return when {
|
||||||
|
isPending -> semantic.onWarningContainer
|
||||||
|
isFailed -> MaterialTheme.colorScheme.error
|
||||||
|
isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> semantic.onSuccessContainer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.ReceiveState
|
import com.bitcointxoko.gudariwallet.ui.ReceiveState
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
||||||
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
||||||
@@ -70,7 +71,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
fun ReceiveScreen(
|
fun ReceiveScreen(
|
||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
nfcVm : NfcViewModel,
|
nfcVm : NfcViewModel,
|
||||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
onNavigateToPaymentDetail : (paymentHash: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -156,16 +157,21 @@ fun ReceiveScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ReceiveState.PaymentReceived -> {
|
is ReceiveState.PaymentReceived -> {
|
||||||
ReceiveSuccessContent(
|
PaymentSuccessContent(
|
||||||
amountSats = state.amountSats,
|
title = strings.receive.paymentReceived,
|
||||||
memo = state.memo,
|
amountSats = state.amountSats,
|
||||||
fiatRate = fiatRate,
|
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||||
fiatCurrency = fiatCurrency,
|
subLine = state.memo?.takeIf { it.isNotBlank() },
|
||||||
onDone = { vm.resetReceiveState() },
|
onDetails = { onNavigateToPaymentDetail(state.paymentHash) },
|
||||||
onDetails = { onNavigateToPaymentDetail(state.checkingId) }
|
secondaryLabel = strings.done,
|
||||||
|
onSecondary = { vm.resetReceiveState() },
|
||||||
|
lnurl = null, // sender identity not available
|
||||||
|
onSaveContact = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
is ReceiveState.Error -> {
|
is ReceiveState.Error -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
@@ -723,50 +729,3 @@ private fun ReceiveInvoiceContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ReceiveSuccessContent ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ReceiveSuccessContent(
|
|
||||||
amountSats : Long,
|
|
||||||
memo : String?,
|
|
||||||
fiatRate : Double?,
|
|
||||||
fiatCurrency : String?,
|
|
||||||
onDone : () -> Unit,
|
|
||||||
onDetails : () -> Unit
|
|
||||||
) {
|
|
||||||
val strings = LocalAppStrings.current
|
|
||||||
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(72.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = strings.receive.paymentReceived, // ← "Payment Received!"
|
|
||||||
style = MaterialTheme.typography.headlineSmall
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
|
||||||
if (!memo.isNullOrBlank()) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(memo, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(strings.details) // ← "Details"
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text(strings.done) // ← "Done"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ReceiveViewModel(
|
|||||||
_receiveState.value = ReceiveState.PaymentReceived(
|
_receiveState.value = ReceiveState.PaymentReceived(
|
||||||
amountSats = event.amountSats,
|
amountSats = event.amountSats,
|
||||||
memo = event.memo,
|
memo = event.memo,
|
||||||
checkingId = rs.paymentHash
|
paymentHash = rs.paymentHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.send
|
package com.bitcointxoko.gudariwallet.ui.send
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
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.offset
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
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.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
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.SuggestionChip
|
||||||
@@ -20,13 +25,18 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
@@ -37,6 +47,8 @@ import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
|||||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
|
||||||
|
private enum class AmountUnit { SATS, FIAT }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LnurlPayForm(
|
internal fun LnurlPayForm(
|
||||||
state : SendState.LnurlReady,
|
state : SendState.LnurlReady,
|
||||||
@@ -47,13 +59,34 @@ internal fun LnurlPayForm(
|
|||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
var amount by remember { mutableStateOf(state.minSats.toString()) }
|
var amountText by remember { mutableStateOf(state.minSats.toString()) }
|
||||||
var comment by remember { mutableStateOf("") }
|
var comment by remember { mutableStateOf("") }
|
||||||
val isFixed = state.minSats == state.maxSats
|
val isFixed = state.minSats == state.maxSats
|
||||||
|
|
||||||
val sats: Long? = amount.toLongOrNull()
|
val showFiatToggle = fiatRate != null && fiatCurrency != null
|
||||||
|
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||||
|
|
||||||
|
// ── Always compute sats from whatever the user typed ─────────────────
|
||||||
|
val parsedInput = amountText.trim().toDoubleOrNull()
|
||||||
|
val sats: Long? = when {
|
||||||
|
parsedInput == null -> null
|
||||||
|
activeUnit == AmountUnit.SATS -> parsedInput.toLong()
|
||||||
|
else /* FIAT */ -> (parsedInput * (fiatRate ?: 1.0)).toLong()
|
||||||
|
}
|
||||||
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)
|
|
||||||
|
// ── Button labels — always sats + fiat, regardless of active unit ────
|
||||||
|
val satsLabelForButton: String = sats?.toString() ?: ""
|
||||||
|
val fiatLabelForButton: String? = if (sats != null) fiatLabel(sats, fiatRate, fiatCurrency) else null
|
||||||
|
|
||||||
|
// ── Conversion label (the opposite unit) for supporting text ─────────
|
||||||
|
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
|
||||||
|
if (!showFiatToggle || parsedInput == null) return@remember null
|
||||||
|
when (activeUnit) {
|
||||||
|
AmountUnit.SATS -> fiatLabel(parsedInput.toLong(), fiatRate, fiatCurrency)
|
||||||
|
AmountUnit.FIAT -> "≈ ${(parsedInput * (fiatRate ?: 1.0)).toLong()} sats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
|
// ── Contact lookup — reactive, sourced from WalletViewModel ──────────
|
||||||
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
|
val existingContact by vm.contactForCurrentLnurl.collectAsStateWithLifecycle()
|
||||||
@@ -67,10 +100,14 @@ internal fun LnurlPayForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The supporting-text height the picker must not center against ─────
|
||||||
|
// OutlinedTextFieldDefaults counts: input area ~56 dp, supporting
|
||||||
|
// text ~16 dp with 4 dp internal padding ≈ 20 dp extra below.
|
||||||
|
val supportingTextHeight = 20.dp
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
// ── Recipient header ──────────────────────────────────────────────
|
// ── Recipient header ──────────────────────────────────────────────
|
||||||
if (existingContact != null) {
|
if (existingContact != null) {
|
||||||
// Known contact — show their name instead of the raw address
|
|
||||||
val contactName = existingContact!!.localAlias
|
val contactName = existingContact!!.localAlias
|
||||||
?: existingContact!!.displayName
|
?: existingContact!!.displayName
|
||||||
?: existingContact!!.name
|
?: existingContact!!.name
|
||||||
@@ -80,7 +117,6 @@ internal fun LnurlPayForm(
|
|||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Unknown — show domain/address as before
|
|
||||||
Text(
|
Text(
|
||||||
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
|
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
@@ -108,45 +144,106 @@ internal fun LnurlPayForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
// ── Headroom above amount row — the picker has a 32 dp ghost item
|
||||||
|
// above the selected item; this offsets so the visual centre of
|
||||||
|
// the picker lines up with the title text above rather than the
|
||||||
|
// Row's top edge.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// ── Amount field — unchanged ──────────────────────────────────────
|
// ── Amount field with currency switcher ───────────────────────────
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = amount,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text(strings.receive.amountInSats) },
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
isError = amount.isNotBlank() && !isAmountValid,
|
) {
|
||||||
supportingText = {
|
|
||||||
when {
|
|
||||||
amount.isNotBlank() && !isAmountValid -> {
|
|
||||||
Text(
|
|
||||||
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
fiatRate != null && fiatCurrency != null -> {
|
|
||||||
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
|
||||||
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
|
||||||
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
|
||||||
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
|
||||||
}
|
|
||||||
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isFixed,
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Comment field — unchanged ─────────────────────────────────────
|
|
||||||
if (state.commentAllowed > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = comment,
|
value = amountText,
|
||||||
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
onValueChange = {
|
||||||
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
val allowed = if (activeUnit == AmountUnit.SATS)
|
||||||
|
it.all(Char::isDigit)
|
||||||
|
else
|
||||||
|
it.all { c -> c.isDigit() || c == '.' } &&
|
||||||
|
it.count { c -> c == '.' } <= 1
|
||||||
|
if (allowed) amountText = it
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = amountText.isNotBlank() && !isAmountValid,
|
||||||
|
supportingText = {
|
||||||
|
when {
|
||||||
|
amountText.isNotBlank() && !isAmountValid -> {
|
||||||
|
Text(
|
||||||
|
text = strings.lnurlAmountError(state.minSats, state.maxSats),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
conversionLabel != null -> {
|
||||||
|
Text(
|
||||||
|
text = conversionLabel,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fiatRate != null && fiatCurrency != null -> {
|
||||||
|
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
||||||
|
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
||||||
|
if (isFixed) Text(strings.lnurlFiatRange(minFiat, maxFiat))
|
||||||
|
else Text(strings.lnurlSatsAndFiatRange(state.minSats, state.maxSats, minFiat, maxFiat))
|
||||||
|
}
|
||||||
|
!isFixed -> Text(strings.receive.satsRange(state.minSats, state.maxSats))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isFixed,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = if (activeUnit == AmountUnit.SATS) KeyboardType.Number else KeyboardType.Decimal,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Offset the picker upward by half the supporting-text height
|
||||||
|
// so its selected item aligns with the text field's input
|
||||||
|
// area (the visible text) rather than the geometric centre of
|
||||||
|
// the entire OutlinedTextField including the sub-label below.
|
||||||
|
UnitWheelPicker(
|
||||||
|
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
|
||||||
|
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
||||||
|
onIndexSelected = { newIndex ->
|
||||||
|
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
||||||
|
if (newUnit != activeUnit) {
|
||||||
|
activeUnit = newUnit
|
||||||
|
amountText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.width(64.dp)
|
||||||
|
.offset(y = -supportingTextHeight / 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gap after amount row — accounts for the picker extending below
|
||||||
|
// (picker is 96 dp, text field input area ~56 dp → ~40 dp
|
||||||
|
// overhang below, minus the offset above). Keep this compact so
|
||||||
|
// all inputs + title + button feel evenly spaced.
|
||||||
|
Spacer(Modifier.height(21.dp))
|
||||||
|
|
||||||
|
// ── Comment field ─────────────────────────────────────────────────
|
||||||
|
if (state.commentAllowed > 0) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
||||||
|
label = { Text(strings.commentOptional(state.commentAllowed)) },
|
||||||
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -154,17 +251,18 @@ internal fun LnurlPayForm(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// ── Pay button — unchanged ────────────────────────────────────────
|
// ── Pay button ────────────────────────────────────────────────────
|
||||||
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,
|
||||||
|
contactId = existingContact?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -172,8 +270,8 @@ internal fun LnurlPayForm(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel)
|
if (fiatLabelForButton != null) strings.lnurlPayButtonWithFiat(satsLabelForButton, fiatLabelForButton)
|
||||||
else strings.lnurlPayButton(amount)
|
else strings.lnurlPayButton(satsLabelForButton)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,4 +289,3 @@ internal fun LnurlPayForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.BalanceState
|
|||||||
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
||||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
@@ -44,6 +45,7 @@ fun SendScreen(
|
|||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
nfcVm : NfcViewModel,
|
nfcVm : NfcViewModel,
|
||||||
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> },
|
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> },
|
||||||
|
onViewContact: ((contactId: String) -> Unit)? = null,
|
||||||
sendStrings: SendStrings
|
sendStrings: SendStrings
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -59,6 +61,17 @@ fun SendScreen(
|
|||||||
}
|
}
|
||||||
val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency
|
val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency
|
||||||
|
|
||||||
|
// ── Consume address prefilled from Contacts tap-to-pay ───────────────────
|
||||||
|
val pendingSend by vm.pendingSend.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(pendingSend) {
|
||||||
|
val address = pendingSend
|
||||||
|
if (!address.isNullOrBlank() && sendState is SendState.Idle) {
|
||||||
|
input = SendInputDetector.normalize(address)
|
||||||
|
vm.scan(address, sendStrings)
|
||||||
|
vm.consumePendingSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Collect NFC offer at top level — never inside a conditional ───────────
|
// ── Collect NFC offer at top level — never inside a conditional ───────────
|
||||||
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
|
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -275,56 +288,34 @@ fun SendScreen(
|
|||||||
} else null
|
} else null
|
||||||
val feeText = when {
|
val feeText = when {
|
||||||
state.feeSats == null || state.feeSats == 0L ->
|
state.feeSats == null || state.feeSats == 0L ->
|
||||||
strings.noFees // ← "No fees"
|
strings.noFees
|
||||||
else -> {
|
else -> {
|
||||||
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
|
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
|
||||||
strings.routingFee(state.feeSats, ppmSuffix) // ← "Routing fee: X sats (Y ppm)"
|
strings.routingFee(state.feeSats, ppmSuffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
PaymentSuccessContent(
|
||||||
modifier = Modifier.fillMaxSize(),
|
title = strings.paymentSent,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
amountSats = state.amountSats,
|
||||||
verticalArrangement = Arrangement.Center
|
fiatLabel = fiatLabel,
|
||||||
) {
|
subLine = feeText,
|
||||||
Icon(
|
detailsEnabled = state.pendingRecord != null,
|
||||||
imageVector = Icons.Filled.CheckCircle,
|
onDetails = {
|
||||||
contentDescription = null,
|
val record = state.pendingRecord
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
if (record != null) onViewDetails(state.paymentHash, record)
|
||||||
modifier = Modifier.size(72.dp)
|
},
|
||||||
)
|
secondaryLabel = strings.sendAnother,
|
||||||
Spacer(Modifier.height(16.dp))
|
onSecondary = { vm.resetSendState(); input = "" },
|
||||||
Text(
|
lnurl = state.lnurl, // null for plain Bolt11
|
||||||
text = strings.paymentSent, // ← "Payment sent!"
|
onSaveContact = vm::saveContactFromSend,
|
||||||
style = MaterialTheme.typography.headlineSmall
|
contactName = state.contactName,
|
||||||
)
|
contactId = state.contactId,
|
||||||
Spacer(Modifier.height(8.dp))
|
onViewContact = onViewContact
|
||||||
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = feeText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val record = state.pendingRecord
|
|
||||||
if (record != null) {
|
|
||||||
onViewDetails(state.paymentHash, record)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = state.pendingRecord != null,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) { Text(strings.details) } // ← "Details"
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
TextButton(
|
|
||||||
onClick = { vm.resetSendState(); input = "" },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) { Text(strings.sendAnother) } // ← "Send Another"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
is SendState.Error -> {
|
is SendState.Error -> {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
import com.bitcointxoko.gudariwallet.api.RouteHintHop
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
|
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.ui.SendState
|
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||||
import com.bitcointxoko.gudariwallet.util.Bip21Parser
|
import com.bitcointxoko.gudariwallet.util.Bip21Parser
|
||||||
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
|
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
|
||||||
@@ -35,35 +38,32 @@ sealed class SendEvent {
|
|||||||
object PaymentCompleted : SendEvent()
|
object PaymentCompleted : SendEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── String bundle injected at the call site ───────────────────────────────────
|
|
||||||
/**
|
|
||||||
* All user-visible strings produced by [SendViewModel].
|
|
||||||
* Populate from [LocalAppStrings.current] in the owning Composable and pass
|
|
||||||
* to [SendViewModel.scan], [SendViewModel.payBolt11], etc.
|
|
||||||
*/
|
|
||||||
data class SendStrings(
|
data class SendStrings(
|
||||||
val lnurlAuthNotSupported : String,
|
val lnurlAuthNotSupported : String,
|
||||||
val unrecognisedInput : String,
|
val unrecognisedInput : String,
|
||||||
val paymentFailed : String,
|
val paymentFailed : String,
|
||||||
val couldNotFetchInvoice : String,
|
val couldNotFetchInvoice : String,
|
||||||
val couldNotDecodeInvoice : String,
|
val couldNotDecodeInvoice : String,
|
||||||
val onChainNotSupported : String,
|
val onChainNotSupported : String,
|
||||||
val channelRequestNotSupported : String,
|
val channelRequestNotSupported : String,
|
||||||
val couldNotResolveAddress : String,
|
val couldNotResolveAddress : String,
|
||||||
val emptyResponse : String,
|
val emptyResponse : String,
|
||||||
val unsupportedType : (tag: String) -> String,
|
val unsupportedType : (tag: String) -> String,
|
||||||
val paymentFailedRescan : String,
|
val paymentFailedRescan : String,
|
||||||
val authFailed : (msg: String) -> String,
|
val authFailed : (msg: String) -> String,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SendViewModel(
|
class SendViewModel(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val lnurlCache : LnurlCacheRepository,
|
private val lnurlCache : LnurlCacheRepository,
|
||||||
private val scanner : LnurlScanner,
|
private val scanner : LnurlScanner,
|
||||||
private val payer : LnurlPayer,
|
private val payer : LnurlPayer,
|
||||||
private val poller : PaymentPoller,
|
private val poller : PaymentPoller,
|
||||||
|
private val contactRepo : ContactRepository, // ← new (Step 5)
|
||||||
|
private val paymentCacheRepo : PaymentCacheRepository, // ← new (Step 8)
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
|
private val _events = MutableSharedFlow<SendEvent>(extraBufferCapacity = 1)
|
||||||
val events: SharedFlow<SendEvent> = _events
|
val events: SharedFlow<SendEvent> = _events
|
||||||
|
|
||||||
@@ -75,17 +75,18 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Entry point ──────────────────────────────────────────────────────────
|
// ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun scan(rawInput: String, strings: SendStrings) {
|
fun scan(rawInput: String, strings: SendStrings) {
|
||||||
val normalized = SendInputDetector.normalize(rawInput)
|
val normalized = SendInputDetector.normalize(rawInput)
|
||||||
val type = SendInputDetector.detect(rawInput)
|
val type = SendInputDetector.detect(rawInput)
|
||||||
Timber.d("SCAN [DETECT] input='$rawInput' → type=$type")
|
Timber.d("SCAN [DETECT] input='$rawInput' → type=$type")
|
||||||
|
|
||||||
when (type) {
|
when (type) {
|
||||||
SendInputType.Unknown -> {
|
SendInputType.Unknown -> {
|
||||||
val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
|
val msg = if (rawInput.trim().lowercase().startsWith("keyauth"))
|
||||||
strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported"
|
strings.lnurlAuthNotSupported
|
||||||
else
|
else
|
||||||
strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address"
|
strings.unrecognisedInput
|
||||||
_sendState.value = SendState.Error(msg)
|
_sendState.value = SendState.Error(msg)
|
||||||
}
|
}
|
||||||
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
|
SendInputType.Bip21 -> handleBip21Scan(rawInput, strings)
|
||||||
@@ -95,10 +96,11 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Payment ──────────────────────────────────────────────────────────────
|
// ── Payment — Bolt11 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun payBolt11(bolt11: String, strings: SendStrings) {
|
fun payBolt11(bolt11: String, strings: SendStrings) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val decoded = _sendState.value as? SendState.Bolt11Decoded
|
val decoded = _sendState.value as? SendState.Bolt11Decoded
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}")
|
Timber.w("SEND [PAY BOLT11] called in unexpected state: ${_sendState.value::class.simpleName}")
|
||||||
return@launch
|
return@launch
|
||||||
@@ -110,138 +112,38 @@ class SendViewModel(
|
|||||||
handlePaymentResult(
|
handlePaymentResult(
|
||||||
paymentHash = response.paymentHash,
|
paymentHash = response.paymentHash,
|
||||||
amountSats = decoded.amountSats,
|
amountSats = decoded.amountSats,
|
||||||
bolt11 = decoded.bolt11,
|
bolt11 = decoded.bolt11,
|
||||||
memo = decoded.memo,
|
memo = decoded.memo,
|
||||||
pubkey = decoded.payee,
|
pubkey = decoded.payee,
|
||||||
alias = decoded.nodeAlias,
|
alias = decoded.nodeAlias,
|
||||||
strings
|
strings = strings,
|
||||||
|
contactId = null, // plain bolt11 path — no contact context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
|
Timber.e("SEND [PAY BOLT11 ERROR] ${e.message}")
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(
|
||||||
parseApiError(e, strings.paymentFailed) // ← "Payment failed"
|
parseApiError(e, strings.paymentFailed)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handlePaymentResult(
|
// ── Payment — LNURL (amount form → confirmation card) ────────────────────
|
||||||
paymentHash : String,
|
|
||||||
amountSats : Long,
|
|
||||||
bolt11 : String?,
|
|
||||||
memo : String?,
|
|
||||||
pubkey : String?,
|
|
||||||
alias : String?,
|
|
||||||
strings : SendStrings,
|
|
||||||
) {
|
|
||||||
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
|
|
||||||
|
|
||||||
when {
|
|
||||||
// ── Settled immediately
|
|
||||||
detail?.paid == true -> {
|
|
||||||
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hold invoice — poll
|
|
||||||
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
|
|
||||||
when (val result = poller.poll(paymentHash)) {
|
|
||||||
is PaymentPoller.PollResult.Settled ->
|
|
||||||
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias)
|
|
||||||
|
|
||||||
is PaymentPoller.PollResult.Failed ->
|
|
||||||
_sendState.value = SendState.Error(strings.paymentFailed)
|
|
||||||
|
|
||||||
is PaymentPoller.PollResult.TimedOut ->
|
|
||||||
_sendState.value = SendState.Error(strings.paymentFailed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// hardening against a null detail
|
|
||||||
detail == null -> {
|
|
||||||
// Detail fetch failed entirely — treat as pending and poll once
|
|
||||||
Timber.w("SEND [DETAIL NULL] polling as fallback")
|
|
||||||
when (val result = poller.poll(paymentHash)) {
|
|
||||||
is PaymentPoller.PollResult.Settled ->
|
|
||||||
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias)
|
|
||||||
else ->
|
|
||||||
_sendState.value = SendState.Error(strings.paymentFailed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Failed or unknown
|
|
||||||
else -> {
|
|
||||||
_sendState.value = SendState.Error(strings.paymentFailed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun emitPaymentSent(
|
|
||||||
paymentHash : String,
|
|
||||||
amountSats : Long,
|
|
||||||
feeSats : Long?,
|
|
||||||
bolt11 : String?,
|
|
||||||
memo : String?,
|
|
||||||
pubkey : String?,
|
|
||||||
alias : String?,
|
|
||||||
) {
|
|
||||||
_sendState.value = SendState.PaymentSent(
|
|
||||||
amountSats = amountSats,
|
|
||||||
feeSats = feeSats,
|
|
||||||
paymentHash = paymentHash,
|
|
||||||
pendingRecord = PaymentRecord.fromPayment(paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias)
|
|
||||||
)
|
|
||||||
_events.tryEmit(SendEvent.PaymentCompleted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun payLnurl(
|
|
||||||
rawRes : LnurlScanResponse,
|
|
||||||
lnurl : String,
|
|
||||||
amountMsat: Long,
|
|
||||||
comment : String?,
|
|
||||||
strings : SendStrings,
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_sendState.value = SendState.Paying
|
|
||||||
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun executeLnurlPayment(
|
|
||||||
rawRes : LnurlScanResponse,
|
|
||||||
lnurl : String,
|
|
||||||
amountMsat: Long,
|
|
||||||
comment : String?,
|
|
||||||
strings : SendStrings,
|
|
||||||
) {
|
|
||||||
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
|
|
||||||
|
|
||||||
if (result.isSuccess) {
|
|
||||||
handlePaymentResult(
|
|
||||||
paymentHash = result.getOrThrow(),
|
|
||||||
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
|
|
||||||
bolt11 = null,
|
|
||||||
memo = null,
|
|
||||||
pubkey = null,
|
|
||||||
alias = null,
|
|
||||||
strings = strings,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
_sendState.value = SendState.Error(
|
|
||||||
parseApiError(
|
|
||||||
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
|
|
||||||
strings.paymentFailed
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from [LnurlPayForm] when the user taps Pay.
|
||||||
|
* Fetches the BOLT-11 from the LNURL callback and moves to [SendState.LnurlInvoiceReady].
|
||||||
|
* [contactId] is the Room PK of the contact associated with this address, or null if unknown.
|
||||||
|
*/
|
||||||
fun fetchLnurlInvoice(
|
fun fetchLnurlInvoice(
|
||||||
rawRes : LnurlScanResponse,
|
rawRes : LnurlScanResponse,
|
||||||
lnurl : String,
|
lnurl : String,
|
||||||
domain : String,
|
domain : String,
|
||||||
amountMsat: Long,
|
amountMsat : Long,
|
||||||
comment : String?,
|
comment : String?,
|
||||||
strings : SendStrings,
|
strings : SendStrings,
|
||||||
|
contactId : String?, // ← new (Step 3)
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_sendState.value = SendState.Scanning
|
_sendState.value = SendState.Scanning
|
||||||
@@ -251,7 +153,7 @@ class SendViewModel(
|
|||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
|
Timber.e("LNURL [FETCH INVOICE ERR] ${e.message}")
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(
|
||||||
parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient"
|
parseApiError(e, strings.couldNotFetchInvoice)
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -261,7 +163,7 @@ class SendViewModel(
|
|||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Timber.e("LNURL [DECODE ERR] ${e.message}")
|
Timber.e("LNURL [DECODE ERR] ${e.message}")
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(
|
||||||
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
|
parseApiError(e, strings.couldNotDecodeInvoice)
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -271,8 +173,11 @@ class SendViewModel(
|
|||||||
hints = decoded.routeHints
|
hints = decoded.routeHints
|
||||||
)
|
)
|
||||||
|
|
||||||
Timber.d("LNURL [INVOICE READY] amountMsat=$amountMsat " +
|
Timber.d(
|
||||||
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size}")
|
"LNURL [INVOICE READY] amountMsat=$amountMsat " +
|
||||||
|
"routeRisk=${routeRisk?.level} hints=${decoded.routeHints.size} " +
|
||||||
|
"contactId=$contactId"
|
||||||
|
)
|
||||||
|
|
||||||
_sendState.value = SendState.LnurlInvoiceReady(
|
_sendState.value = SendState.LnurlInvoiceReady(
|
||||||
bolt11 = bolt11,
|
bolt11 = bolt11,
|
||||||
@@ -290,7 +195,8 @@ class SendViewModel(
|
|||||||
routeHintAliases = emptyMap(),
|
routeHintAliases = emptyMap(),
|
||||||
paymentHash = decoded.paymentHash,
|
paymentHash = decoded.paymentHash,
|
||||||
createdAt = decoded.createdAt,
|
createdAt = decoded.createdAt,
|
||||||
expiresAt = decoded.expiresAt
|
expiresAt = decoded.expiresAt,
|
||||||
|
contactId = contactId, // ← new (Step 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
resolveAliasesInBackground(
|
resolveAliasesInBackground(
|
||||||
@@ -309,6 +215,10 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the confirmation card. Tries direct bolt11 pay first; falls back to
|
||||||
|
* the LNURL-pay protocol. In both cases [contactId] is threaded through to [emitPaymentSent].
|
||||||
|
*/
|
||||||
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
|
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_sendState.value = SendState.Paying
|
_sendState.value = SendState.Paying
|
||||||
@@ -323,17 +233,192 @@ class SendViewModel(
|
|||||||
pubkey = state.payee,
|
pubkey = state.payee,
|
||||||
alias = state.nodeAlias,
|
alias = state.nodeAlias,
|
||||||
strings = strings,
|
strings = strings,
|
||||||
|
contactId = state.contactId, // ← new (Step 5)
|
||||||
)
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
Timber.w("LNURL [PAY INVOICE DIRECT FAIL] " +
|
Timber.w(
|
||||||
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl")
|
"LNURL [PAY INVOICE DIRECT FAIL] " +
|
||||||
executeLnurlPayment(state.rawRes, state.lnurl, state.amountMsat, state.comment, strings)
|
"${directResult.exceptionOrNull()?.message} — falling back to payLnurl"
|
||||||
|
)
|
||||||
|
executeLnurlPayment(
|
||||||
|
rawRes = state.rawRes,
|
||||||
|
lnurl = state.lnurl,
|
||||||
|
amountMsat = state.amountMsat,
|
||||||
|
comment = state.comment,
|
||||||
|
strings = strings,
|
||||||
|
contactId = state.contactId, // ← new (Step 5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct LNURL-pay path (no pre-fetched invoice). Used by [payLnurl] which is called
|
||||||
|
* from the amount form when the user bypasses the confirmation card.
|
||||||
|
*/
|
||||||
|
fun payLnurl(
|
||||||
|
rawRes : LnurlScanResponse,
|
||||||
|
lnurl : String,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment : String?,
|
||||||
|
strings : SendStrings,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_sendState.value = SendState.Paying
|
||||||
|
// No contactId available on this path (legacy/manual scan)
|
||||||
|
executeLnurlPayment(rawRes, lnurl, amountMsat, comment, strings, contactId = null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetSendState() { _sendState.value = SendState.Idle }
|
fun resetSendState() { _sendState.value = SendState.Idle }
|
||||||
|
|
||||||
|
// ── Private: payment execution ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun executeLnurlPayment(
|
||||||
|
rawRes : LnurlScanResponse,
|
||||||
|
lnurl : String,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment : String?,
|
||||||
|
strings : SendStrings,
|
||||||
|
contactId : String?, // ← new (Step 5)
|
||||||
|
) {
|
||||||
|
val result = payer.pay(rawRes, lnurl, amountMsat, comment)
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
handlePaymentResult(
|
||||||
|
paymentHash = result.getOrThrow(),
|
||||||
|
amountSats = amountMsat / WalletConstants.MSAT_PER_SAT,
|
||||||
|
bolt11 = null,
|
||||||
|
memo = null,
|
||||||
|
pubkey = null,
|
||||||
|
alias = null,
|
||||||
|
strings = strings,
|
||||||
|
contactId = contactId, // ← new (Step 5)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_sendState.value = SendState.Error(
|
||||||
|
parseApiError(
|
||||||
|
result.exceptionOrNull() ?: Exception(strings.paymentFailed),
|
||||||
|
strings.paymentFailed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handlePaymentResult(
|
||||||
|
paymentHash : String,
|
||||||
|
amountSats : Long,
|
||||||
|
bolt11 : String?,
|
||||||
|
memo : String?,
|
||||||
|
pubkey : String?,
|
||||||
|
alias : String?,
|
||||||
|
strings : SendStrings,
|
||||||
|
contactId : String?, // ← new (Step 5)
|
||||||
|
) {
|
||||||
|
val detail = runCatching { repo.getPaymentDetail(paymentHash) }.getOrNull()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// ── Settled immediately
|
||||||
|
detail?.paid == true -> {
|
||||||
|
emitPaymentSent(paymentHash, amountSats, detail.details?.feeSat, bolt11, memo, pubkey, alias, contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hold invoice — poll
|
||||||
|
detail?.details?.status == "pending" || detail?.details?.pending == true -> {
|
||||||
|
when (val result = poller.poll(paymentHash)) {
|
||||||
|
is PaymentPoller.PollResult.Settled ->
|
||||||
|
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
|
||||||
|
|
||||||
|
is PaymentPoller.PollResult.Failed ->
|
||||||
|
_sendState.value = SendState.Error(strings.paymentFailed)
|
||||||
|
|
||||||
|
is PaymentPoller.PollResult.TimedOut ->
|
||||||
|
_sendState.value = SendState.Error(strings.paymentFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail fetch failed entirely — treat as pending and poll once
|
||||||
|
detail == null -> {
|
||||||
|
Timber.w("SEND [DETAIL NULL] polling as fallback")
|
||||||
|
when (val result = poller.poll(paymentHash)) {
|
||||||
|
is PaymentPoller.PollResult.Settled ->
|
||||||
|
emitPaymentSent(paymentHash, amountSats, result.feeSats, bolt11, memo, pubkey, alias, contactId)
|
||||||
|
else ->
|
||||||
|
_sendState.value = SendState.Error(strings.paymentFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Failed or unknown
|
||||||
|
else -> {
|
||||||
|
_sendState.value = SendState.Error(strings.paymentFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal success path. Order of operations is deliberate:
|
||||||
|
*
|
||||||
|
* 1. Resolve contact name (read-only, no ordering constraint)
|
||||||
|
* 2. Upsert the payment row — guarantees the FK exists in the payments table
|
||||||
|
* 3. Write the TxContactCrossRef — FK now satisfied, cannot dangle
|
||||||
|
* 4. Emit [SendState.PaymentSent] — UI unblocks only after all DB writes are committed
|
||||||
|
*/
|
||||||
|
private suspend fun emitPaymentSent(
|
||||||
|
paymentHash : String,
|
||||||
|
amountSats : Long,
|
||||||
|
feeSats : Long?,
|
||||||
|
bolt11 : String?,
|
||||||
|
memo : String?,
|
||||||
|
pubkey : String?,
|
||||||
|
alias : String?,
|
||||||
|
contactId : String?, // ← new (Step 5/8)
|
||||||
|
) {
|
||||||
|
val pendingRecord = PaymentRecord.fromPayment(
|
||||||
|
paymentHash, amountSats, feeSats, bolt11, memo, pubkey, alias
|
||||||
|
)
|
||||||
|
|
||||||
|
var contactName: String? = null
|
||||||
|
|
||||||
|
if (contactId != null) {
|
||||||
|
// ── 1. Resolve display name ──────────────────────────────────────
|
||||||
|
val contact: ContactEntity? = try {
|
||||||
|
contactRepo.getById(contactId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w("SEND [CONTACT LOOKUP] could not resolve contact $contactId: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
contactName = contact?.localAlias
|
||||||
|
?: contact?.displayName
|
||||||
|
?: contact?.name
|
||||||
|
|
||||||
|
// ── 2. Guarantee the payment row exists before writing the FK ────
|
||||||
|
// upsertPayment uses INSERT OR REPLACE — idempotent if the
|
||||||
|
// background sync already wrote this row.
|
||||||
|
runCatching { paymentCacheRepo.upsertPayment(pendingRecord) }
|
||||||
|
.onFailure { Timber.e("SEND [UPSERT ERR] $paymentHash: ${it.message}") }
|
||||||
|
|
||||||
|
// ── 3. Link payment → contact (FK now guaranteed to exist) ───────
|
||||||
|
runCatching {
|
||||||
|
contactRepo.linkPaymentToContact(
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
contactId = contactId,
|
||||||
|
role = "recipient",
|
||||||
|
)
|
||||||
|
}.onFailure { Timber.e("SEND [LINK ERR] $paymentHash → $contactId: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Unblock UI — only after all DB writes complete ────────────────
|
||||||
|
_sendState.value = SendState.PaymentSent(
|
||||||
|
amountSats = amountSats,
|
||||||
|
feeSats = feeSats,
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
pendingRecord = pendingRecord,
|
||||||
|
contactId = contactId,
|
||||||
|
contactName = contactName,
|
||||||
|
)
|
||||||
|
_events.tryEmit(SendEvent.PaymentCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scan handlers ────────────────────────────────────────────────────────
|
// ── Scan handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
|
private fun handleBip21Scan(rawInput: String, strings: SendStrings) {
|
||||||
@@ -343,18 +428,14 @@ class SendViewModel(
|
|||||||
when {
|
when {
|
||||||
bolt11 != null -> scan(bolt11, strings)
|
bolt11 != null -> scan(bolt11, strings)
|
||||||
lnurl != null -> scan(lnurl, strings)
|
lnurl != null -> scan(lnurl, strings)
|
||||||
else -> _sendState.value = SendState.Error(
|
else -> _sendState.value = SendState.Error(strings.onChainNotSupported)
|
||||||
strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
|
private fun handleLud17Scan(rawInput: String, strings: SendStrings) {
|
||||||
val scheme = rawInput.trim().lowercase().substringBefore("://")
|
val scheme = rawInput.trim().lowercase().substringBefore("://")
|
||||||
if (scheme == "lnurlc") {
|
if (scheme == "lnurlc") {
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(strings.channelRequestNotSupported)
|
||||||
strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
|
val httpsUrl = "https://" + rawInput.trim().substringAfter("://")
|
||||||
@@ -365,20 +446,20 @@ class SendViewModel(
|
|||||||
.onSuccess { raw ->
|
.onSuccess { raw ->
|
||||||
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
|
Timber.d("LUD17 [RESPONSE] tag=${raw.tag}")
|
||||||
when (raw.tag) {
|
when (raw.tag) {
|
||||||
"withdrawRequest" -> {emitWithdrawScanned(raw, httpsUrl)}
|
"withdrawRequest" -> emitWithdrawScanned(raw, httpsUrl)
|
||||||
"payRequest" -> {
|
"payRequest" -> {
|
||||||
lnurlCache.put(httpsUrl, raw)
|
lnurlCache.put(httpsUrl, raw)
|
||||||
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
|
_sendState.value = buildLnurlReadyState(raw, httpsUrl)
|
||||||
}
|
}
|
||||||
else -> _sendState.value = SendState.Error(
|
else -> _sendState.value = SendState.Error(
|
||||||
strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag"
|
strings.unsupportedType(raw.tag ?: "unknown")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Timber.e("LUD17 [ERROR] ${e.message}")
|
Timber.e("LUD17 [ERROR] ${e.message}")
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(
|
||||||
parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address"
|
parseApiError(e, strings.couldNotResolveAddress)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,7 +504,7 @@ class SendViewModel(
|
|||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Timber.e("SEND [DECODE ERROR] ${e.message}")
|
Timber.e("SEND [DECODE ERROR] ${e.message}")
|
||||||
_sendState.value = SendState.Error(
|
_sendState.value = SendState.Error(
|
||||||
parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice"
|
parseApiError(e, strings.couldNotDecodeInvoice)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,10 +528,10 @@ class SendViewModel(
|
|||||||
if (!fromCache) lnurlCache.put(normalized, raw)
|
if (!fromCache) lnurlCache.put(normalized, raw)
|
||||||
|
|
||||||
when (raw.tag) {
|
when (raw.tag) {
|
||||||
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
|
"payRequest" -> _sendState.value = buildLnurlReadyState(raw, normalized)
|
||||||
"withdrawRequest" -> emitWithdrawScanned(raw, normalized)
|
"withdrawRequest" -> emitWithdrawScanned(raw, normalized)
|
||||||
null -> _sendState.value = SendState.Error(strings.emptyResponse)
|
null -> _sendState.value = SendState.Error(strings.emptyResponse)
|
||||||
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
|
else -> _sendState.value = SendState.Error(strings.unsupportedType(raw.tag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,17 +559,17 @@ class SendViewModel(
|
|||||||
payee : String?,
|
payee : String?,
|
||||||
routeHints : List<RouteHintHop>,
|
routeHints : List<RouteHintHop>,
|
||||||
copyAlias : (SendState, String) -> SendState?,
|
copyAlias : (SendState, String) -> SendState?,
|
||||||
copyHint : (SendState, String, String) -> SendState?
|
copyHint : (SendState, String, String) -> SendState?,
|
||||||
) {
|
) {
|
||||||
payee?.let { pubkey ->
|
payee?.let { pubkey ->
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val alias = aliasRepo.resolve(pubkey) ?: return@launch
|
val alias = aliasRepo.resolve(pubkey) ?: return@launch
|
||||||
_sendState.update { copyAlias(it, alias) ?: it }
|
_sendState.update { copyAlias(it, alias) ?: it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
|
routeHints.map { it.publicKey }.distinct().forEach { pubkey ->
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val alias = aliasRepo.resolve(pubkey) ?: return@launch
|
val alias = aliasRepo.resolve(pubkey) ?: return@launch
|
||||||
_sendState.update { copyHint(it, pubkey, alias) ?: it }
|
_sendState.update { copyHint(it, pubkey, alias) ?: it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,81 @@
|
|||||||
package com.bitcointxoko.gudariwallet.util
|
package com.bitcointxoko.gudariwallet.util
|
||||||
|
|
||||||
/**
|
import java.text.DecimalFormatSymbols
|
||||||
* Converts a satoshi amount to its fiat equivalent.
|
import java.util.Locale
|
||||||
*
|
|
||||||
* @param sats Amount in satoshis.
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
* @param satsPerFiat How many sats equal 1 fiat unit (e.g. if BTC = $100,000 then
|
|
||||||
* satsPerFiat = 100,000,000 / 100,000 = 1,000 sats per dollar).
|
|
||||||
* @return Fiat amount as a Double.
|
|
||||||
*/
|
|
||||||
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
|
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
|
||||||
sats / satsPerFiat
|
sats / satsPerFiat
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a fiat amount for display with a currency symbol prefix.
|
|
||||||
*
|
|
||||||
* Tiers (bitcoin.design rules):
|
|
||||||
* - amount >= 1.00 → 2 dp, e.g. "$43.25"
|
|
||||||
* - 0.01 <= amount < 1.00 → 4 dp, e.g. "$0.0043"
|
|
||||||
* - amount < 0.01 (sub-cent)→ dynamic precision with "~" prefix,
|
|
||||||
* e.g. "~$0.0039" (2 significant digits after leading zeros)
|
|
||||||
* - amount == 0.0 → "$0.00"
|
|
||||||
*
|
|
||||||
* @param amount Fiat amount to format (always pass a positive value; sign is handled by caller).
|
|
||||||
* @param currency ISO 4217 currency code (e.g. "USD", "EUR").
|
|
||||||
* @return Formatted string with symbol, e.g. "$43.25" or "~$0.0039".
|
|
||||||
*/
|
|
||||||
fun formatFiat(amount: Double, currency: String): String {
|
fun formatFiat(amount: Double, currency: String): String {
|
||||||
val symbol = currencySymbol(currency)
|
val symbol = currencySymbol(currency)
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
amount == 0.0 -> "${symbol}0.00"
|
amount == 0.0 -> "${symbol}${localiseDecimal("0.00")}"
|
||||||
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
|
amount >= 1.0 -> {
|
||||||
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
|
val formatted = "%.2f".format(Locale.US, amount)
|
||||||
else -> formatSubCent(amount, symbol)
|
val isExact = formatted.toDouble() == amount
|
||||||
|
val prefix = if (isExact) "" else "~"
|
||||||
|
"$prefix$symbol${localiseDecimal(formatted)}"
|
||||||
|
}
|
||||||
|
amount >= 0.01 -> formatWithSigDigits(amount, symbol, minDecimals = 2)
|
||||||
|
else -> formatWithSigDigits(amount, symbol, minDecimals = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts sats → fiat and formats in one call, applying bitcoin.design display rules.
|
|
||||||
*
|
|
||||||
* Use this everywhere you display a sat amount alongside a fiat equivalent.
|
|
||||||
*
|
|
||||||
* @param sats Absolute satoshi count (always positive; caller adds +/− sign).
|
|
||||||
* @param satsPerFiat Exchange rate: sats per 1 fiat unit.
|
|
||||||
* @param currency ISO 4217 currency code.
|
|
||||||
* @return Formatted fiat string, e.g. "$12.50", "~$0.0039", or "$0.00".
|
|
||||||
*/
|
|
||||||
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
|
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
|
||||||
formatFiat(satsToFiat(sats, satsPerFiat), currency)
|
formatFiat(satsToFiat(sats, satsPerFiat), currency)
|
||||||
|
|
||||||
|
fun fiatLabel(
|
||||||
|
sats: Long?,
|
||||||
|
fiatRate: Double?,
|
||||||
|
fiatCurrency: String?,
|
||||||
|
minSats: Long = 1L
|
||||||
|
): String? =
|
||||||
|
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
|
||||||
|
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
|
||||||
|
else null
|
||||||
|
|
||||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
private fun formatWithSigDigits(amount: Double, symbol: String, minDecimals: Int): String {
|
||||||
* Handles the sub-cent case (0 < amount < 0.01).
|
// Use Locale.US so "." is always the decimal char — safe for arithmetic & comparison
|
||||||
*
|
val raw = "%.15f".format(Locale.US, amount)
|
||||||
* Finds the first two significant (non-zero) digits after the decimal point
|
val dotIdx = raw.indexOf('.')
|
||||||
* and rounds to that precision, prefixing with "~" to signal approximation.
|
if (dotIdx == -1) return "$symbol${localiseDecimal(raw)}"
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* 0.003887 → "~$0.0039"
|
|
||||||
* 0.000038 → "~$0.000038"
|
|
||||||
* 0.0000431 → "~$0.000043"
|
|
||||||
*/
|
|
||||||
private fun formatSubCent(amount: Double, symbol: String): String {
|
|
||||||
// Render with enough decimal places to capture at least 2 significant digits
|
|
||||||
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
|
|
||||||
val dotIdx = raw.indexOf('.')
|
|
||||||
if (dotIdx == -1) return "~$symbol$raw"
|
|
||||||
|
|
||||||
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000"
|
val decimals = raw.substring(dotIdx + 1)
|
||||||
val firstSigIdx = decimals.indexOfFirst { it != '0' }
|
val firstSigIdx = decimals.indexOfFirst { it != '0' }
|
||||||
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
|
|
||||||
|
|
||||||
// Keep all leading zeros + 2 significant digits
|
if (firstSigIdx == -1) return "${symbol}${localiseDecimal("0.00")}"
|
||||||
val precision = firstSigIdx + 2
|
|
||||||
val rounded = "%.${precision}f".format(amount)
|
|
||||||
|
|
||||||
// Only add "~" if rounding actually changed the value
|
val sigPrecision = firstSigIdx + 2
|
||||||
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
|
val precision = maxOf(sigPrecision, minDecimals)
|
||||||
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
|
|
||||||
|
val rounded = "%.${precision}f".format(Locale.US, amount)
|
||||||
|
val isExact = rounded.toDouble() == amount // safe: dot separator guaranteed
|
||||||
|
|
||||||
|
val prefix = if (isExact) "" else "~"
|
||||||
|
return "$prefix$symbol${localiseDecimal(rounded)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the display symbol for a given ISO 4217 currency code.
|
* Converts a US-formatted decimal string (dot separator, no grouping)
|
||||||
* Falls back to the currency code + space for unlisted currencies.
|
* into the user's current locale format.
|
||||||
|
*
|
||||||
|
* "155.57" → "155,57" (DE locale)
|
||||||
|
* "0.0039" → "0,0039" (DE locale)
|
||||||
|
* "155.57" → "155.57" (US locale)
|
||||||
*/
|
*/
|
||||||
|
private fun localiseDecimal(usFormatted: String): String {
|
||||||
|
val symbols = DecimalFormatSymbols.getInstance() // uses device default locale
|
||||||
|
val localeDecimalSep = symbols.decimalSeparator
|
||||||
|
// Only substitute if the locale actually uses a different separator
|
||||||
|
return if (localeDecimalSep == '.') usFormatted
|
||||||
|
else usFormatted.replace('.', localeDecimalSep)
|
||||||
|
}
|
||||||
|
|
||||||
private fun currencySymbol(currency: String): String = when (currency) {
|
private fun currencySymbol(currency: String): String = when (currency) {
|
||||||
"USD" -> "$"
|
"USD" -> "$"
|
||||||
"EUR" -> "€"
|
"EUR" -> "€"
|
||||||
@@ -96,12 +87,3 @@ private fun currencySymbol(currency: String): String = when (currency) {
|
|||||||
"CHF" -> "Fr"
|
"CHF" -> "Fr"
|
||||||
else -> "$currency "
|
else -> "$currency "
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a formatted fiat string for [sats] at the given rate, or null if
|
|
||||||
* rate or currency is unavailable. Suppresses display for zero/null amounts.
|
|
||||||
*/
|
|
||||||
fun fiatLabel(sats: Long?, fiatRate: Double?, fiatCurrency: String?, minSats: Long = 1L): String? =
|
|
||||||
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
|
|
||||||
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
|
|
||||||
else null
|
|
||||||
|
|||||||
Reference in New Issue
Block a user