77 Commits

Author SHA1 Message Date
rasputin e0b68f49b0 Merge pull request 'Unify payment item' (#1) from unify-payment-item into main
Reviewed-on: #1
2026-06-27 11:13:26 +00:00
rasputin 5f0badcdf5 bug fixes 2026-06-15 00:33:29 +02:00
rasputin 3c5539d2cb wire up historyviewmodel 2026-06-14 14:55:55 +02:00
rasputin 19970659b8 repository combine layer 2026-06-14 14:16:41 +02:00
rasputin c6885f6307 create mapper functions 2026-06-14 14:03:37 +02:00
rasputin d5c461c2e6 define ui facing models 2026-06-14 14:03:21 +02:00
rasputin 985c80373d fiat ux 2026-06-14 13:30:11 +02:00
rasputin bcea6451e7 contact details ui 2026-06-14 12:47:28 +02:00
rasputin 53cb600408 contact details ui 2026-06-13 23:22:27 +02:00
rasputin effe8f4b04 fix: contact name wiped on refresh, migrate to paymentHash instead of checkingId 2026-06-13 22:52:12 +02:00
rasputin c817768e8a enhancement: search contacts 2026-06-13 20:57:17 +02:00
rasputin 0e7469053d enhancement: navigate to contact details from successful send 2026-06-13 20:16:40 +02:00
rasputin ebeac511b7 enhancement: show contact on successful send 2026-06-13 20:01:34 +02:00
rasputin 2e11cecbeb refactor: extract PaymentSuccessContent 2026-06-13 17:50:17 +02:00
rasputin e57414de17 feat: send from contact details 2026-06-13 16:09:58 +02:00
rasputin 0aa55710a9 enhancement: better contact detail screen ux 2026-06-13 15:57:46 +02:00
rasputin ef03a986a4 fix: multiple filters break contact search 2026-06-13 14:53:31 +02:00
rasputin 2bec9f3908 feat: search payments by contact 2026-06-13 14:30:17 +02:00
rasputin f9991622b3 feat: show contact name on payment rows 2026-06-13 14:09:33 +02:00
rasputin f931405c90 feat: add contacts 1 2026-06-13 14:00:59 +02:00
rasputin c3490808c0 localisation: app biometric lock 2026-06-12 21:44:43 +02:00
rasputin 4546844ea9 feat: app biometric lock 2026-06-12 21:30:37 +02:00
rasputin 181cf56239 localisation: nwc 2026-06-12 20:51:30 +02:00
rasputin 262d5f068d localisation: nwc 2026-06-12 20:50:36 +02:00
rasputin 201242c797 enhancement: show relative days 2026-06-12 19:05:28 +02:00
rasputin 8c84eca8eb refactor: make linter happy 2026-06-12 18:23:18 +02:00
rasputin 4e22f4d3e9 refactor: make linter happy 2026-06-12 18:20:26 +02:00
rasputin c155f43870 refactor: make linter happy 2026-06-12 18:15:04 +02:00
rasputin f2d6aeb13b test: add checksum and signature validation tests to Bolt11Decoder 2026-06-12 17:32:23 +02:00
rasputin 91dc24d1e1 feat: add checksum and signature validation to Bolt11Decoder 2026-06-12 17:08:35 +02:00
rasputin 321c3d9c55 test: add util tests 2026-06-12 16:14:08 +02:00
rasputin 4756681a2a refactor: fix build.gradle.kts lint errors 2026-06-12 13:50:12 +02:00
rasputin cba4a740a0 refactor: target SDK 37, upgrade dependencies 2026-06-12 13:40:34 +02:00
rasputin 8baa9ad398 refactor: upgrade okhttp and loggingInterceptor 2026-06-12 13:09:30 +02:00
rasputin bd0c8e477b refactor: upgrade composeBom 2026-06-12 13:08:20 +02:00
rasputin 25855ad7b3 refactor: upgrade kotlin and ksp 2026-06-12 13:06:53 +02:00
rasputin 3c834efb20 refactor: upgrade tink and protobuf 2026-06-12 13:01:10 +02:00
rasputin 2edd30ac21 refactor: downgrade biometric to stable 2026-06-12 12:52:56 +02:00
rasputin 8e20959bbb refactor: update non-breaking dependencies 2026-06-12 12:51:07 +02:00
rasputin 16e955e553 refactor: make LNbitsHttpClient.instance internal 2026-06-12 12:25:30 +02:00
rasputin 4694b6d44e refactor: clean up LNbitsApi 2026-06-12 12:14:01 +02:00
rasputin aaadd0d73f refactor: code smell runBlocking inside an OkHttp Interceptor 2026-06-12 12:03:36 +02:00
rasputin 98bc466edd refactor: disable backup 2026-06-12 11:38:48 +02:00
rasputin 23e92ea8db refactor: remove permissions used for testing 2026-06-12 11:34:52 +02:00
rasputin cad54c69e7 feat: nwc caching 2026-06-12 11:29:45 +02:00
rasputin 25d13b5a0b refactor: i18n HistoryStrings 2026-06-11 23:37:16 +02:00
rasputin 868869fd88 refactor: i18n HistoryStrings 2026-06-11 23:34:48 +02:00
rasputin b053da6a05 refactor: i18n ReceiveStrings 2026-06-11 23:25:54 +02:00
rasputin 78eb034327 fix: send payment shows as failed even when succeeded 2026-06-11 20:34:00 +02:00
rasputin a2754a7168 add macrobenchmark 2026-06-11 11:13:39 +02:00
rasputin 787acff1e8 add leakcanary 2026-06-11 01:33:31 +02:00
rasputin 39c31f96a8 fix: r8 broke workmanager 2026-06-11 01:22:48 +02:00
rasputin e242c7b5d0 refactor: migrate to kotlinx serialization, eliminate gson dependency 2026-06-10 23:06:11 +02:00
rasputin d1c3722781 refactor: r8 build optmisations 2026-06-10 21:49:52 +02:00
rasputin 3c9b480a9e refactor: extract common ui components 2026-06-10 19:30:44 +02:00
rasputin b90fdca83a refactor: UnitWheelPicker to ui/common 2026-06-10 19:19:42 +02:00
rasputin 8405f33c47 refactor: BrightnessController and QrDisplayCard to ui/common 2026-06-10 19:05:53 +02:00
rasputin e71a0ffdb9 refactor: DateTimeUtils and PaymentUtils 2026-06-10 18:48:16 +02:00
rasputin 2bc92fca06 refactor: observe pairingUrl state 2026-06-10 18:38:15 +02:00
rasputin a121d40bbd refactor: add connections sheet 2026-06-10 18:34:08 +02:00
rasputin e5f8156ecf refactor: extract BrightnessUtils, use snackbar instead of toast 2026-06-10 18:18:14 +02:00
rasputin 9ba408e157 refactor: fix loading flash 2026-06-10 17:32:50 +02:00
rasputin f9ddebac8a refactor: wrap with try finally 2026-06-10 16:54:53 +02:00
rasputin 8be9d8ea43 refactor: extract add connection sheet from nwc 2026-06-10 16:52:37 +02:00
rasputin af59b385e6 refactor: extract pairing dialogue from nwc 2026-06-10 16:44:48 +02:00
rasputin a22e5e4628 refactor: extract connection row from nwc 2026-06-10 16:39:28 +02:00
rasputin 28b366d81f refactor: extract states from nwc 2026-06-10 16:32:09 +02:00
rasputin 08a76e00d9 refactor: extract models and utils from nwc 2026-06-10 16:29:11 +02:00
rasputin 237cc74519 feat: improve nwc qr code ux 2026-06-10 16:08:38 +02:00
rasputin 2ef9a52e6e fix: okhttp url interceptor 2026-06-10 15:47:04 +02:00
rasputin 05a31070e8 fix: nwc ui 2026-06-10 12:40:02 +02:00
rasputin 2ff28720f8 fix: nwc revoke alert 2026-06-09 18:51:54 +02:00
rasputin 4a2b66dcf2 feat: nwc details 2026-06-09 18:17:32 +02:00
rasputin 7fad5ec9dd feat: nwc 2026-06-09 15:48:38 +02:00
rasputin 2490ea2f75 fix: adminKey ignored 2026-06-09 00:46:14 +02:00
rasputin 767d7d19cf fix: production build config 2026-06-08 23:05:18 +02:00
rasputin 36d04f5c87 fix: production build config 2026-06-08 23:05:04 +02:00
159 changed files with 12274 additions and 2741 deletions
+2 -35
View File
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<DropdownSelection timestamp="2026-06-11T09:22:11.580091Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
@@ -13,40 +13,7 @@
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="getAll_baseline()">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="PaymentDaoBenchmark">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="concurrentReadWrite()">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="checkJournalMode()">
<SelectionState runConfigName="macrobenchmark">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT">
+23
View File
@@ -2,17 +2,40 @@
<dictionary name="project">
<words>
<w>Aead</w>
<w>Bech</w>
<w>Bitrefill</w>
<w>Español</w>
<w>Nbits</w>
<w>Nostr</w>
<w>Tink</w>
<w>amboss</w>
<w>bech</w>
<w>bitcointxoko</w>
<w>btcpay</w>
<w>cltv</w>
<w>getnwckey</w>
<w>gudari</w>
<w>gudariwallet</w>
<w>hkdf</w>
<w>inflight</w>
<w>keyauth</w>
<w>lnaddr</w>
<w>lnaddress</w>
<w>lnbc</w>
<w>lnbits</w>
<w>lnurlp</w>
<w>lnurlw</w>
<w>msat</w>
<w>msats</w>
<w>nostr</w>
<w>nwccacherepository</w>
<w>satoshi</w>
<w>satoshis</w>
<w>sats</w>
<w>snackbar</w>
<w>snull</w>
<w>tink</w>
<w>walletconnect</w>
</words>
</dictionary>
</component>
+1
View File
@@ -10,6 +10,7 @@
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/macrobenchmark" />
</set>
</option>
</GradleProjectSettings>
+55 -23
View File
@@ -1,4 +1,9 @@
import com.google.protobuf.gradle.*
import java.util.Properties
val localProps = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
configurations.all {
resolutionStrategy {
@@ -12,49 +17,64 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.bitcointxoko.gudariwallet"
compileSdk {
version = release(36) {
minorApiLevel = 1
compileSdk = 37
splits {
abi {
isEnable = true
reset() // Clear all defaults
include("arm64-v8a") // Specify desired ABIs
isUniversalApk = false
}
}
defaultConfig {
applicationId = "com.bitcointxoko.gudariwallet"
minSdk = 26
targetSdk = 36
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
androidResources {
@Suppress("UnstableApiUsage")
localeFilters += setOf("en", "es", "eu")
}
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "ACTIVITY-MISSING"
}
testBuildType = "benchmark"
signingConfigs {
create("release") {
storeFile = file(localProps["KEYSTORE_PATH"] as String)
storePassword = localProps["KEYSTORE_PASSWORD"] as String
keyAlias = localProps["KEY_ALIAS"] as String
keyPassword = localProps["KEY_PASSWORD"] as String
}
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true // Enables R8/ProGuard shrinking
isShrinkResources = true // Removes unused resources
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
create("benchmark") {
initWith(buildTypes.getByName("release"))
isMinifyEnabled = false // benchmarks must not be minified
//noinspection NotShrinkingResources
isShrinkResources = false // or results are meaningless
signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment
installation {
enableBaselineProfile = false // ← disables profile embedding for this build type only
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
@@ -67,13 +87,17 @@ android {
}
}
getByName("debug") {
java.srcDirs("build/generated/ksp/debug/kotlin")
java.directories.add("build/generated/ksp/debug/kotlin")
}
getByName("test") {
java.srcDirs("src/test/java")
kotlin.srcDirs("src/test/java")
}
getByName("release") {
java.srcDirs("build/generated/ksp/release/kotlin")
java.directories.add("build/generated/ksp/release/kotlin")
}
getByName("benchmark") {
java.srcDirs("build/generated/ksp/benchmark/kotlin")
java.directories.add("build/generated/ksp/benchmark/kotlin")
}
}
}
@@ -82,6 +106,7 @@ androidComponents {
onVariants(selector().withBuildType("benchmark")) { variant ->
variant.packaging.resources.excludes.add("baseline-prof.txt")
variant.packaging.resources.excludes.add("baseline.profm")
variant.packaging.resources.excludes.add("**/*.profm")
}
}
@@ -98,11 +123,12 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.ui)
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.mockk)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.benchmark.junit4)
androidTestImplementation(libs.androidx.benchmark.junit4)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -110,9 +136,9 @@ dependencies {
// Networking
implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson)
implementation(libs.converter.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
debugImplementation(libs.logging.interceptor)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
@@ -141,7 +167,7 @@ dependencies {
implementation(libs.androidx.biometric)
// JSON
implementation(libs.gson)
implementation(libs.kotlinx.serialization.json)
// icons
implementation(libs.androidx.material.icons.extended)
@@ -151,7 +177,6 @@ dependencies {
//Navigation
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.appcompat)
// animations
@@ -159,6 +184,8 @@ dependencies {
// secp256k1
implementation(libs.secp256k1.kmp.android)
testImplementation(libs.secp256k1.kmp.jvm)
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.15.0")
// lyricist
implementation(libs.lyricist)
@@ -168,6 +195,11 @@ dependencies {
// timber
implementation(libs.timber)
// leakcanary
debugImplementation(libs.leakcanary.android)
testImplementation("org.json:json:20240303")
}
protobuf {
+145 -17
View File
@@ -1,21 +1,149 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# ──────────────────────────────────────────────────────────────────
# Protobuf lite
# ──────────────────────────────────────────────────────────────────
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
# ──────────────────────────────────────────────────────────────────
# Google Tink 1.13.0
# ──────────────────────────────────────────────────────────────────
-keepclassmembers class * extends com.google.crypto.tink.shaded.protobuf.GeneratedMessageLite {
<fields>;
}
-keep class com.google.crypto.tink.KeyTemplates { *; }
-keep class com.google.crypto.tink.StreamingAead { *; }
-keep class com.google.crypto.tink.KeysetHandle { *; }
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager { *; }
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder { *; }
-keep class com.google.crypto.tink.streamingaead.StreamingAeadConfig { *; }
-keep class com.google.crypto.tink.streamingaead.AesGcmHkdfStreamingKeyManager { *; }
-keep class com.google.crypto.tink.streamingaead.StreamingAeadWrapper { *; }
-keep class com.google.crypto.tink.subtle.AesGcmHkdfStreaming { *; }
-dontwarn com.google.crypto.tink.**
# ──────────────────────────────────────────────────────────────────
# kotlinx.serialization
# The compiler plugin generates a $serializer inner class for every
# @Serializable class. R8 must not remove or rename these they are
# looked up by name at runtime via SerializersModule.
# ──────────────────────────────────────────────────────────────────
-keepattributes *Annotation*, InnerClasses
-keep class kotlinx.serialization.SerializersKt { *; }
-dontnote kotlinx.serialization.**
-dontwarn kotlinx.serialization.**
# Keep generated serializers for all @Serializable classes in your app
-keepclassmembers class com.bitcointxoko.gudariwallet.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep the $serializer companion objects themselves
-keep class com.bitcointxoko.gudariwallet.**$$serializer { *; }
# Required for sealed class polymorphic serialization
-keepclassmembers @kotlinx.serialization.Serializable class * {
*** Companion;
*** INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# ──────────────────────────────────────────────────────────────────
# Retrofit 3.0.0
# ──────────────────────────────────────────────────────────────────
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
-keep,allowobfuscation,allowshrinking class * extends kotlin.coroutines.CoroutineContext$Element { *; }
# ──────────────────────────────────────────────────────────────────
# OkHttp 5.x / Okio
# ──────────────────────────────────────────────────────────────────
-keep class okhttp3.internal.publicsuffix.PublicSuffixDatabase { *; }
-keepnames class okhttp3.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-dontwarn org.codehaus.mojo.animal_sniffer.*
# ──────────────────────────────────────────────────────────────────
# secp256k1-kmp (JNI)
# ──────────────────────────────────────────────────────────────────
-keep class fr.acinq.secp256k1.** { *; }
-keepclasseswithmembernames class * {
native <methods>;
}
# ──────────────────────────────────────────────────────────────────
# ZXing 4.3.0
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# ZXing uses reflection to instantiate Reader subclasses by class name.
# Only the classes your app actually exercises need to be kept.
# A Lightning wallet only needs QR code scanning (QR_CODE format).
# ──────────────────────────────────────────────────────────────────
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Core entry points used directly via the API
-keep class com.google.zxing.BarcodeFormat { *; }
-keep class com.google.zxing.DecodeHintType { *; }
-keep class com.google.zxing.Result { *; }
-keep class com.google.zxing.ResultMetadataType { *; }
-keep class com.google.zxing.MultiFormatReader { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# QR code reader instantiated reflectively by MultiFormatReader
-keep class com.google.zxing.qrcode.QRCodeReader { *; }
-keep class com.google.zxing.qrcode.decoder.** { *; }
-keep class com.google.zxing.qrcode.detector.** { *; }
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# QR code generator
-keep class com.google.zxing.qrcode.QRCodeWriter { *; }
# Common infrastructure used by all readers
-keep class com.google.zxing.common.** { *; }
# Suppress warnings for unused format readers (1D barcodes, DataMatrix, etc.)
# that are in the jar but not needed by a wallet app
-dontwarn com.google.zxing.**
# ──────────────────────────────────────────────────────────────────
# Room 2.7.1
# ──────────────────────────────────────────────────────────────────
-keep @androidx.room.Entity class *
-keep @androidx.room.Dao interface *
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.**
# ──────────────────────────────────────────────────────────────────
# WorkManager 2.9.1
# ──────────────────────────────────────────────────────────────────
-keep class * extends androidx.work.InputMerger { *; }
-keep class * extends androidx.work.Worker { *; }
-keep class * extends androidx.work.CoroutineWorker { *; }
-keep class * extends androidx.work.ListenableWorker { *; }
-keep class * extends androidx.work.WorkerFactory { *; }
# Keep specific classes explicitly as a safety net
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker { *; }
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker$Companion { *; }
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorkerFactory { *; }
# ──────────────────────────────────────────────────────────────────
# Kotlin Coroutines 1.11.0
# ──────────────────────────────────────────────────────────────────
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-dontwarn kotlinx.coroutines.**
-dontwarn kotlinx.coroutines.debug.*
# ──────────────────────────────────────────────────────────────────
# Lyricist 1.8.0
# ──────────────────────────────────────────────────────────────────
-keep class com.bitcointxoko.gudariwallet.**Strings { *; }
-keep class com.bitcointxoko.gudariwallet.**Translations { *; }
# ──────────────────────────────────────────────────────────────────
# Timber 5.0.1
# ──────────────────────────────────────────────────────────────────
-dontwarn org.jetbrains.annotations.**
# ──────────────────────────────────────────────────────────────────
# Stack traces upload mapping.txt to Play Console / Crashlytics
# ──────────────────────────────────────────────────────────────────
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
+42
View File
@@ -0,0 +1,42 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.bitcointxoko.gudariwallet",
"variantName": "release",
"elements": [
{
"type": "ONE_OF_MANY",
"filters": [
{
"filterType": "ABI",
"value": "arm64-v8a"
}
],
"attributes": [],
"versionCode": 1,
"versionName": "0.1.0",
"outputFile": "app-arm64-v8a-release.apk"
}
],
"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
}
+10 -3
View File
@@ -10,16 +10,23 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<queries>
<!-- Allows resolveActivity() to see apps that handle the NWC URI scheme -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="nostr+walletconnect" />
</intent>
</queries>
<application
android:name=".GudariWalletApplication"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration
get() {
val secrets = EncryptedSecretStore(this)
val api = LNbitsClient.create(secrets)
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val client = LNbitsClient(secrets)
val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
val paymentCache = PaymentCacheRepository(this)
val syncStore = HistoricalSyncStore(this)
return Configuration.Builder()
@@ -29,7 +29,7 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
NotificationHelper.createChannels(this) // moved from MainActivity
NotificationHelper.createChannels(this)
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -37,12 +31,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings
import com.bitcointxoko.gudariwallet.data.AppLockStore
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
import com.bitcointxoko.gudariwallet.util.NotificationConstants
class MainActivity : AppCompatActivity() {
private lateinit var secretStore: EncryptedSecretStore
private lateinit var appLockStore: AppLockStore
private val isUnlocked = mutableStateOf(false)
private val vm: WalletViewModel by viewModels {
WalletViewModelFactory(application)
@@ -90,6 +89,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext)
appLockStore = AppLockStore(applicationContext)
val syncStore = HistoricalSyncStore(applicationContext)
val credentialsState = secretStore.credentials
.stateIn(
@@ -97,6 +97,12 @@ class MainActivity : AppCompatActivity() {
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
val appLockEnabledState = appLockStore.appLockEnabled
.stateIn(
scope = lifecycleScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = true // safe default: locked
)
// Must be first — before any notification is posted
// NotificationHelper.createChannels(applicationContext)
@@ -127,34 +133,49 @@ class MainActivity : AppCompatActivity() {
// ─────────────────────────────────────────────────────────────
val credentials by credentialsState.collectAsState()
val appLockEnabled by appLockEnabledState.collectAsState()
val isOnboarded = credentials?.isOnboarded == true
if (isOnboarded) {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
when {
!isOnboarded -> {
OnboardingScreen(
secretStore = secretStore,
syncStore = syncStore,
appLockStore = appLockStore,
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
onComplete = {}
)
}
appLockEnabled && !isUnlocked.value -> {
BiometricLockScreen(
onUnlocked = { isUnlocked.value = true }
)
}
else -> {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingNavigateToHistory = pendingNavigateToHistory,
pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist
)
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingNavigateToHistory = pendingNavigateToHistory,
pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist
)
} else {
OnboardingScreen(
secretStore = secretStore,
syncStore = syncStore,
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
onComplete = {}
)
}
}
}
}
}
override fun onStop() {
super.onStop()
isUnlocked.value = false
}
// ── NFC foreground dispatch ───────────────────────────────────────────────
override fun onResume() {
@@ -1,9 +1,16 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
/**
* LNbits returns `comment` in PaymentExtra as either:
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
* - a JSON array ["Thanks!"]
* - null
*
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter
* on the field for extra safety.
* Mirrors the behavior of the old Gson FlexibleStringAdapter.
*/
class FlexibleStringAdapter : TypeAdapter<String?>() {
object FlexibleStringSerializer : KSerializer<String?> {
override fun write(out: JsonWriter, value: String?) {
if (value == null) out.nullValue() else out.value(value)
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("FlexibleString")
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: String?) {
if (value == null) encoder.encodeNull()
else encoder.encodeString(value)
}
override fun read(reader: JsonReader): String? {
return when (reader.peek()) {
JsonToken.NULL -> { reader.nextNull(); null }
JsonToken.BEGIN_ARRAY -> {
val parts = mutableListOf<String>()
reader.beginArray()
while (reader.hasNext()) {
when (reader.peek()) {
JsonToken.NULL -> reader.nextNull()
else -> parts.add(reader.nextString())
}
}
reader.endArray()
parts.joinToString(", ").ifEmpty { null }
}
else -> reader.nextString()
override fun deserialize(decoder: Decoder): String? {
val jsonDecoder = decoder as JsonDecoder
return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonNull -> null
is JsonPrimitive -> element.content // plain string → as-is
is JsonArray -> element // array → join elements
.mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
.joinToString(", ")
.ifEmpty { null }
else -> null
}
}
}
@@ -1,21 +0,0 @@
package com.bitcointxoko.gudariwallet.api
import com.google.gson.Gson
import com.google.gson.GsonBuilder
/**
* Single source of truth for the app's Gson instance.
*
* All serialization/deserialization — Retrofit, SharedPreferences caches,
* and WebSocket message parsing — must use this instance so that registered
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
* everywhere.
*
* To add a new adapter: register it here once. It will automatically apply
* to every deserialization path in the app.
*/
object GsonProvider {
val gson: Gson = GsonBuilder()
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
.create()
}
@@ -0,0 +1,24 @@
package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.json.Json
/**
* Single source of truth for the app's JSON instance.
*
* All serialization/deserialization — Retrofit, DataStore caches,
* and WebSocket message parsing — must use this instance so that
* configuration is applied consistently everywhere.
*
* Custom type serializers (e.g. [SuccessActionSerializer],
* [FlexibleStringSerializer]) are registered directly on the model
* classes via @Serializable(with = ...) so no explicit registration
* is needed here.
*/
object JsonProvider {
val json: Json = Json {
ignoreUnknownKeys = true // tolerate extra fields from the API
isLenient = true // accept unquoted/malformed JSON from some endpoints
explicitNulls = false // omit null fields when serializing requests
encodeDefaults = true
}
}
@@ -1,9 +1,11 @@
package com.bitcointxoko.gudariwallet.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
@@ -13,7 +15,7 @@ interface LNbitsApi {
/** GET /api/v1/wallet — returns balance and wallet info */
@GET("api/v1/wallet")
suspend fun getWallet(
@Header("X-Api-Key") apiKey: String
@Header("X-Api-Key") invoiceKey: String
): WalletResponse
/** POST /api/v1/payments — creates a BOLT-11 invoice */
@@ -42,10 +44,10 @@ interface LNbitsApi {
): PayInvoiceResponse
/** GET /api/v1/payments — returns payment history */
@GET("api/v1/payments")
suspend fun getPayments(
@Header("X-Api-Key") apiKey: String
): List<WsPayment>
// @GET("api/v1/payments")
// suspend fun getPayments(
// @Header("X-Api-Key") apiKey: String
// ): List<WsPayment>
/** GET /api/v1/payments/paginated — returns bulk payment history */
@GET("api/v1/payments/paginated")
@@ -144,4 +146,61 @@ interface LNbitsApi {
*/
@GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String>
// ── NWC Provider ──────────────────────────────────────────────────────────
@GET("nwcprovider/api/v1/permissions")
suspend fun getNwcPermissions(
@Header("X-Api-Key") invoiceKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/nwc")
suspend fun getNwcKeys(
@Header("X-Api-Key") adminKey: String,
@Query("include_expired") includeExpired: Boolean = false
): List<NwcGetResponse>
@GET("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun getNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Query("include_expired") includeExpired: Boolean = false,
@Query("refresh_last_used") refreshLastUsed: Boolean = false
): NwcGetResponse
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun registerNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Body request: NwcRegistrationRequest
): NwcGetResponse
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun deleteNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String
)
@GET("nwcprovider/api/v1/pairing/{secret}")
suspend fun getNwcPairingUrl(
@Header("X-Api-Key") invoiceKey: String,
@Path("secret") secret: String
): String
@GET("nwcprovider/api/v1/config")
suspend fun getNwcConfig(
@Header("X-Api-Key") adminKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/config/{key}")
suspend fun getNwcConfigValue(
@Header("X-Api-Key") adminKey: String,
@Path("key") key: String
): Map<String, Any>
@POST("nwcprovider/api/v1/config")
suspend fun setNwcConfig(
@Header("X-Api-Key") adminKey: String,
@Body config: Map<String, Any>
): Map<String, Any>
}
@@ -1,87 +1,88 @@
package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.atomic.AtomicReference
object LNbitsClient {
class LNbitsClient(secrets: SecretStore) {
// Single shared client — used by both Retrofit and WebSocket
val httpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
// Scope lives as long as this client — SupervisorJob so one failure
// doesn't cancel the URL-watching coroutine.
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Cached in memory. Starts blank; populated as soon as DataStore emits.
private val cachedBaseUrl = AtomicReference("")
init {
scope.launch {
// Suspend until DataStore has emitted at least once.
// This primes cachedBaseUrl before any request can realistically fire.
val initial = secrets.credentials.first()
cachedBaseUrl.set(initial.baseUrl)
// Then keep it updated reactively for the lifetime of this client.
secrets.credentials.collect { credentials ->
cachedBaseUrl.set(credentials.baseUrl)
}
}
.build()
}
fun create(secrets: SecretStore): LNbitsApi {
// Auth header interceptor — reads invoice key per-request (unchanged)
val authInterceptor = Interceptor { chain ->
val key = runBlocking { secrets.invoiceKey() }
chain.proceed(
chain.request().newBuilder()
.header("X-Api-Key", key)
.build()
)
}
val api: LNbitsApi by lazy { buildApi() }
// Dynamic base URL interceptor — rewrites the placeholder host to the
// real base URL at request time, not at Retrofit construction time.
// This means credentials only need to be in DataStore by the time the
// first HTTP request fires, not at app startup.
private fun buildApi(): LNbitsApi {
val dynamicBaseUrlInterceptor = Interceptor { chain ->
val realBaseUrl = runBlocking { secrets.baseUrl() }
.ifBlank { "https://placeholder.invalid" }
.trimEnd('/')
val realHttpUrl = realBaseUrl.toHttpUrl()
val originalRequest = chain.request()
val originalUrl = originalRequest.url
if (originalUrl.host != "placeholder.invalid") {
return@Interceptor chain.proceed(originalRequest)
}
val raw = cachedBaseUrl.get().ifBlank { null }
?: throw IllegalStateException(
"LNbits base URL is not configured. " +
"Complete onboarding before making API calls."
)
val realHttpUrl = raw.trimEnd('/').toHttpUrl()
val originalUrl = chain.request().url
val newUrl = originalUrl.newBuilder()
.scheme(realHttpUrl.scheme)
.host(realHttpUrl.host)
.port(realHttpUrl.port)
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
.encodedPath(
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
)
.build()
chain.proceed(
chain.request().newBuilder()
.url(newUrl)
.build()
)
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
val client = httpClient.newBuilder()
.addInterceptor(authInterceptor)
val client = LNbitsHttpClient.instance.newBuilder()
.addInterceptor(dynamicBaseUrlInterceptor)
.apply {
// Logging goes LAST — after URL rewrite — so logs show the real URL
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
}
.build()
return Retrofit.Builder()
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
// always overwritten by the interceptor
.baseUrl("https://placeholder.invalid/")
// ↑ Structurally required: Retrofit validates that baseUrl is a
// well-formed absolute URL at construction time. The interceptor
// rewrites it before any real request leaves the device.
.client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
.addConverterFactory(
JsonProvider.json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
)
)
.build()
.create(LNbitsApi::class.java)
}
@@ -0,0 +1,18 @@
package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.util.WalletConstants
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
// internal = visible within this Gradle module only.
// Intentionally not private: WalletNotificationService accesses this
// directly because Android instantiates Services via reflection —
// constructor injection is not possible. All other consumers must use
// LNbitsClient.api instead.
object LNbitsHttpClient {
internal val instance: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
.build()
}
@@ -1,133 +1,147 @@
package com.bitcointxoko.gudariwallet.api
import androidx.compose.runtime.Immutable
import com.google.gson.TypeAdapter
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
// ── Wallet ────────────────────────────────────────────────────────────────────
@Serializable
data class WalletResponse(
val id: String,
val name: String,
val balance: Long // millisatoshis
val id: String = "",
val name: String = "",
val balance: Long = 0L // millisatoshis
)
// ── Create invoice ────────────────────────────────────────────────────────────
@Serializable
data class CreateInvoiceRequest(
val out: Boolean,
val amount: Long, // satoshis
val memo: String
)
@Serializable
data class CreateInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("payment_request") val paymentRequest: String,
@SerializedName("expiry") val expiry : String?
@SerialName("payment_hash") val paymentHash: String = "",
@SerialName("payment_request") val paymentRequest: String = "",
@SerialName("expiry") val expiry: String? = null
)
// ── Pay invoice ───────────────────────────────────────────────────────────────
@Serializable
data class PayInvoiceRequest(
val out: Boolean,
val bolt11: String
)
@Serializable
data class PayInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String
@SerialName("payment_hash") val paymentHash: String = "",
@SerialName("checking_id") val checkingId: String = ""
)
// ── Check payment ─────────────────────────────────────────────────────────────
@Serializable
data class PaymentStatusResponse(
val paid: Boolean,
val paid: Boolean = false,
val details: PaymentDetails? = null
)
@Serializable
data class PaymentDetails(
val amount: Long?,
val fee: Long?,
val memo: String?
val amount: Long? = null,
val fee: Long? = null,
val memo: String? = null
)
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
@Serializable
data class DecodeInvoiceRequest(
val data: String
)
@Serializable
data class DecodeInvoiceResponse(
@SerializedName("payment_hash") val paymentHash : String? = null,
@SerializedName("amount_msat") val amountMsat : Long? = null,
@SerializedName("description") val description : String? = null,
@SerializedName("payee") val payee : String? = null,
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
@SerialName("payment_hash") val paymentHash: String? = null,
@SerialName("amount_msat") val amountMsat: Long? = null,
@SerialName("description") val description: String? = null,
@SerialName("payee") val payee: String? = null,
@SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
)
@Serializable
data class RouteHintHop(
@SerializedName("public_key") val publicKey : String,
@SerializedName("short_channel_id") val shortChannelId : String? = null,
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
@SerializedName("ppm_fee") val ppmFee : Long = 0,
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
@SerialName("public_key") val publicKey: String = "",
@SerialName("short_channel_id") val shortChannelId: String? = null,
@SerialName("base_fee") val baseFeeMsat: Long = 0,
@SerialName("ppm_fee") val ppmFee: Long = 0,
@SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
)
// ── LNURL scan ────────────────────────────────────────────────────────────────
@Serializable
data class LnurlScanResponse(
val tag: String?,
// ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null,
@SerializedName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ───────────────────────────────────────────────
val k1 : String? = null,
@SerializedName("defaultDescription")
val defaultDescription: String? = null,
@SerializedName("minWithdrawable")
val minWithdrawable : Long? = null,
@SerializedName("maxWithdrawable")
val maxWithdrawable : Long? = null,
@SerializedName("balanceCheck")
val balanceCheck : String? = null,
@SerializedName("currentBalance")
val currentBalance : Long? = null,
// ── Error fields ─────────────────────────────────────────────────────────
val status : String? = null, // "OK" or "ERROR"
val reason : String? = null // error message when status == "ERROR"
val tag: String? = null,
// ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null,
@SerialName("minSendable") val minSendable: Long? = null,
@SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null,
@SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerialName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null,
@SerialName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ───────────────────────────────────────────────
val k1: String? = null,
@SerialName("defaultDescription") val defaultDescription: String? = null,
@SerialName("minWithdrawable") val minWithdrawable: Long? = null,
@SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
@SerialName("balanceCheck") val balanceCheck: String? = null,
@SerialName("currentBalance") val currentBalance: Long? = null,
// ── Error fields ──────────────────────────────────────────────────────────
val status: String? = null, // "OK" or "ERROR"
val reason: String? = null // error message when status == "ERROR"
)
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
@Serializable
data class LnurlCallbackResponse(
val pr : String, // BOLT-11 invoice to pay
val routes : List<Any>? = null,
@SerializedName("successAction")
val successAction : SuccessAction? = null,
// Error fields (server may return status=ERROR instead of pr)
val status : String? = null,
val reason : String? = null
val pr: String = "",
val routes: List<JsonElement>? = null,
@SerialName("successAction") val successAction: SuccessAction? = null,
val status: String? = null,
val reason: String? = null
)
// ── LNURL pay ─────────────────────────────────────────────────────────────────
@Serializable
data class LnurlPayRes(
val tag: String?,
val callback: String?,
@SerializedName("minSendable") val minSendable: Long?,
@SerializedName("maxSendable") val maxSendable: Long?,
val metadata: String?,
@SerializedName("commentAllowed") val commentAllowed: Int?,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
val tag: String? = null,
val callback: String? = null,
@SerialName("minSendable") val minSendable: Long? = null,
@SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null,
@SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerialName("nostrPubkey") val nostrPubkey: String? = null
)
@Serializable
data class PayLnurlRequest(
val res: LnurlPayRes,
val lnurl: String,
@@ -135,45 +149,48 @@ data class PayLnurlRequest(
val comment: String? = null
)
@Serializable
data class PayLnurlResponse(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String
@SerialName("payment_hash") val paymentHash: String = "",
@SerialName("checking_id") val checkingId: String = ""
)
@Serializable
data class WithdrawCallbackResponse(
val status : String,
val reason : String? = null
val status: String = "",
val reason: String? = null
)
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
@Serializable
data class LnurlpLink(
val id : String,
val wallet : String,
val username : String?, // the part before the @ in the lightning address
val description : String?,
val min : Long,
val max : Long
val id: String = "",
val wallet: String = "",
val username: String? = null,
val description: String? = null,
@Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
@Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
)
// ── Payment history ───────────────────────────────────────────────────────────
@Immutable
@Serializable
data class PaymentRecord(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String,
@SerializedName("amount") val amountMsat: Long,
@SerializedName("fee") val feeMsat: Long,
@SerializedName("memo") val memo: String?,
@SerializedName("time") val time: String,
@SerializedName("status") val status: String,
@SerializedName("bolt11") val bolt11: String?,
@SerializedName("pending") val pending: Boolean,
@SerializedName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra?,
@SerialName("payment_hash") val paymentHash: String = "",
@SerialName("checking_id") val checkingId: String = "",
@SerialName("amount") val amountMsat: Long = 0L,
@SerialName("fee") val feeMsat: Long = 0L,
@SerialName("memo") val memo: String? = null,
@SerialName("time") val time: String = "",
@SerialName("status") val status: String = "",
@SerialName("bolt11") val bolt11: String? = null,
@SerialName("pending") val pending: Boolean = false,
@SerialName("preimage") val preimage: String? = null,
@SerialName("extra") val extra: PaymentExtra? = null,
// Not from the API — populated only when loaded from Room via toDomain()
val createdAt: Long? = null,
val pubkey: String? = null,
val alias: String? = null
val pubkey: String? = null,
val alias: String? = null
) {
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
@@ -181,13 +198,13 @@ data class PaymentRecord(
companion object {
fun fromPayment(
paymentHash : String,
amountSats : Long,
feeSats : Long?,
bolt11 : String?,
memo : String?,
pubkey : String?,
alias : String?,
paymentHash: String,
amountSats: Long,
feeSats: Long?,
bolt11: String?,
memo: String?,
pubkey: String?,
alias: String?,
): PaymentRecord = PaymentRecord(
paymentHash = paymentHash,
checkingId = paymentHash,
@@ -207,23 +224,25 @@ data class PaymentRecord(
}
@Immutable
@Serializable
data class PaymentExtra(
@SerializedName("tag") val tag: String?,
@JsonAdapter(FlexibleStringAdapter::class)
val comment: String?,
@SerializedName("success_action") val successAction: SuccessAction?,
@SerializedName("wallet_id") val walletId: String?,
@SerializedName("destination") val destination: String?,
@SerializedName("expires_at") val expiresAt: String?
@SerialName("tag") val tag: String? = null,
@Serializable(with = FlexibleStringSerializer::class)
val comment: String? = null,
@SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
val successAction: SuccessAction? = null,
@SerialName("wallet_id") val walletId: String? = null,
@SerialName("destination") val destination: String? = null,
@SerialName("expires_at") val expiresAt: String? = null
)
@Immutable
@Serializable(with = SuccessActionSerializer::class)
data class SuccessAction(
@SerializedName("tag") val tag: String?,
@SerializedName("message") val message: String?,
@SerializedName("url") val url: String?,
@SerializedName("description") val description: String?
@SerialName("tag") val tag: String?,
@SerialName("message") val message: String?,
@SerialName("url") val url: String?,
@SerialName("description") val description: String?
)
/**
@@ -232,93 +251,158 @@ data class SuccessAction(
* - a plain string "Thanks!"
* - null
*
* Gson's default deserialiser throws IllegalStateException when it sees a
* string where it expects {. This adapter handles all three cases.
* kotlinx.serialization's default deserializer throws when it sees a
* string where it expects {. This serializer handles all three cases.
*
* Registered centrally in [GsonProvider] — applies to all deserialization
* paths automatically.
* Applied via @Serializable(with = SuccessActionSerializer::class) on
* the SuccessAction class and on PaymentExtra.successAction.
*/
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
object SuccessActionSerializer : KSerializer<SuccessAction?> {
override fun write(out: JsonWriter, value: SuccessAction?) {
if (value == null) { out.nullValue(); return }
out.beginObject()
out.name("tag"); out.value(value.tag)
out.name("message"); out.value(value.message)
out.name("url"); out.value(value.url)
out.name("description"); out.value(value.description)
out.endObject()
override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("SuccessAction")
override fun serialize(encoder: Encoder, value: SuccessAction?) {
val jsonEncoder = encoder as JsonEncoder
if (value == null) {
jsonEncoder.encodeJsonElement(JsonNull)
} else {
jsonEncoder.encodeJsonElement(
JsonObject(
mapOf(
"tag" to JsonPrimitive(value.tag),
"message" to JsonPrimitive(value.message),
"url" to JsonPrimitive(value.url),
"description" to JsonPrimitive(value.description)
)
)
)
}
}
override fun read(reader: JsonReader): SuccessAction? {
return when (reader.peek()) {
JsonToken.NULL -> { reader.nextNull(); null }
// Plain string — treat as a message-type success action
JsonToken.STRING -> {
val text = reader.nextString()
override fun deserialize(decoder: Decoder): SuccessAction? {
val jsonDecoder = decoder as JsonDecoder
return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonNull -> null
is JsonPrimitive -> {
// Plain string — treat as a message-type success action
val text = element.jsonPrimitive.content
if (text.isBlank()) null
else SuccessAction(tag = "message", message = text, url = null, description = null)
}
// Normal object
JsonToken.BEGIN_OBJECT -> {
reader.beginObject()
var tag: String? = null
var message: String? = null
var url: String? = null
var description: String? = null
while (reader.hasNext()) {
when (reader.nextName()) {
"tag" -> tag = reader.nextString()
"message" -> message = reader.nextString()
"url" -> url = reader.nextString()
"description" -> description = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
SuccessAction(tag = tag, message = message, url = url, description = description)
is JsonObject -> {
SuccessAction(
tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
message = element.jsonObject["message"]?.jsonPrimitive?.content,
url = element.jsonObject["url"]?.jsonPrimitive?.content,
description = element.jsonObject["description"]?.jsonPrimitive?.content
)
}
else -> { reader.skipValue(); null }
else -> null
}
}
}
@Serializable
data class PaymentDetailResponse(
val paid: Boolean,
val details: PaymentRecord?
)
// ── WebSocket payment message ─────────────────────────────────────────────────
@Serializable
data class WsPaymentMessage(
@SerializedName("wallet_balance") val walletBalance: Long?,
val payment: WsPayment?
@SerialName("wallet_balance") val walletBalance: Long? = null,
val payment: WsPayment? = null
)
@Serializable
data class WsPayment(
@SerializedName("payment_hash") val paymentHash: String,
@SerializedName("checking_id") val checkingId: String,
@SerializedName("amount") val amount: Long,
@SerializedName("fee") val fee: Long,
@SerializedName("memo") val memo: String?,
@SerializedName("time") val time: String,
@SerializedName("status") val status: String,
@SerializedName("bolt11") val bolt11: String?,
@SerializedName("pending") val pending: Boolean,
@SerializedName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra?
@SerialName("payment_hash") val paymentHash: String = "",
@SerialName("checking_id") val checkingId: String = "",
@SerialName("amount") val amount: Long = 0L,
@SerialName("fee") val fee: Long = 0L,
@SerialName("memo") val memo: String? = null,
@SerialName("time") val time: String = "",
@SerialName("status") val status: String = "",
@SerialName("bolt11") val bolt11: String? = null,
@SerialName("pending") val pending: Boolean = false,
@SerialName("preimage") val preimage: String? = null,
@SerialName("extra") val extra: PaymentExtra? = null
)
// ── Fiat rate ────────────────────────────────────────────────────────────────
// ── Fiat rate ─────────────────────────────────────────────────────────────────
@Serializable
data class FiatRateResponse(
val rate: Double, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency
val rate: Double = 0.0, // sat to fiat rate
val price: Double = 0.0 // BTC price in the requested fiat currency
)
// ── History sync ─────────────────────────────────────────────────────────────
// ── History sync ─────────────────────────────────────────────────────────────
@Serializable
data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>,
@SerializedName("total") val total: Int
@SerialName("data") val data: List<PaymentRecord> = emptyList(),
@SerialName("total") val total: Int = 0
)
// ── NWC Provider models ───────────────────────────────────────────────────────
@Serializable
data class NwcKey(
val pubkey: String = "",
val wallet: String = "",
val description: String = "",
val expires_at: Long = 0L,
val permissions: String = "",
val created_at: Long = 0L,
val last_used: Long = 0L
)
@Serializable
data class NwcBudget(
val id: Int = 0,
val pubkey: String = "",
val budget_msats: Long = 0L,
val refresh_window: Int = 0,
val created_at: Long = 0L,
val used_budget_msats: Long = 0L
)
@Serializable
data class NwcGetResponse(
val data: NwcKey = NwcKey(),
val budgets: List<NwcBudget> = emptyList()
)
// Request models — no defaults needed (you control the fields, never deserializing)
@Serializable
data class NwcNewBudget(
val budget_msats: Long,
val refresh_window: Int,
val created_at: Long = System.currentTimeMillis() / 1000L
)
@Serializable
data class NwcRegistrationRequest(
val permissions: List<String>,
val description: String,
val expires_at: Long,
val budgets: List<NwcNewBudget> = emptyList()
)
/**
* Deserializes a JSON number that may arrive as either an integer (1)
* or a float (1.0) into a Long, truncating any fractional part.
*/
object LongAsDoubleSerializer : KSerializer<Long> {
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
override fun deserialize(decoder: Decoder): Long {
val jsonDecoder = decoder as JsonDecoder
return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonPrimitive -> element.content.toDouble().toLong()
else -> 0L
}
}
}
@@ -1,10 +1,12 @@
package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Path
// ── mempool.space ─────────────────────────────────────────────────────────────
@Serializable
data class MempoolNodeResponse(
val alias: String?,
val public_key: String?
@@ -17,6 +19,7 @@ interface MempoolApi {
// ── 1ml.com ───────────────────────────────────────────────────────────────────
@Serializable
data class OneMlNodeResponse(
val alias: String?,
val pub_key: String?
@@ -1,8 +1,9 @@
package com.bitcointxoko.gudariwallet.api
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
/**
@@ -17,17 +18,20 @@ class NodeAliasClient {
.readTimeout(5, TimeUnit.SECONDS)
.build()
private val converter = JsonProvider.json
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
private val mempoolApi: MempoolApi = Retrofit.Builder()
.baseUrl("https://mempool.space/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(converter)
.build()
.create(MempoolApi::class.java)
private val oneMlApi: OneMlApi = Retrofit.Builder()
.baseUrl("https://1ml.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(converter)
.build()
.create(OneMlApi::class.java)
@@ -0,0 +1,27 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.appLockDataStore by preferencesDataStore(name = "app_lock_prefs")
class AppLockStore(private val context: Context) {
companion object {
private val KEY_APP_LOCK_ENABLED = booleanPreferencesKey("app_lock_enabled")
}
/** Defaults to true (locked) as required. */
val appLockEnabled: Flow<Boolean> = context.appLockDataStore.data
.map { prefs -> prefs[KEY_APP_LOCK_ENABLED] ?: true }
suspend fun setAppLockEnabled(enabled: Boolean) {
context.appLockDataStore.edit { prefs ->
prefs[KEY_APP_LOCK_ENABLED] = enabled
}
}
}
@@ -0,0 +1,200 @@
package com.bitcointxoko.gudariwallet.data
import android.app.Application
import com.bitcointxoko.gudariwallet.data.db.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
class ContactRepository(app: Application) {
private val dao = AppDatabase.getInstance(app).contactDao()
// --- Observe ---
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
dao.observeAllWithAddresses()
fun observeContact(id: String): Flow<ContactWithAddresses?> =
dao.observeById(id)
/** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>> =
dao.observePaymentContactSummaries()
// --- Create ---
suspend fun createContact(
localAlias: String,
lnAddress: String? = null,
lnUrl: String? = null,
pictureUrl: String? = null
): ContactEntity {
val contact = ContactEntity(
localAlias = localAlias,
displayName = null,
name = null,
pictureUrl = pictureUrl,
nostrPubkey = null,
nip05 = null,
about = null,
lnAddress = lnAddress
)
dao.insert(contact)
// Also add to payment_addresses table if lnAddress provided
lnAddress?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
address = it,
isDefault = true
)
)
}
lnUrl?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LNURL.name,
address = it,
isDefault = lnAddress == null // default only if no lnAddress
)
)
}
return contact
}
// --- Update ---
suspend fun updateAlias(id: String, newAlias: String) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
}
suspend fun updatePicture(id: String, pictureUrl: String?) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
}
// --- Payment addresses ---
suspend fun addAddress(
contactId: String,
type: PaymentAddressType,
address: String,
label: String? = null,
makeDefault: Boolean = false
) {
if (makeDefault) dao.clearDefault(contactId, type.name)
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
dao.getById(contactId)?.let {
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
}
}
dao.insertAddress(
PaymentAddressEntity(
contactId = contactId,
type = type.name,
address = address,
label = label,
isDefault = makeDefault
)
)
}
suspend fun 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) {
dao.deleteAddressById(addressId)
}
// --- Delete ---
suspend fun deleteContact(id: String) {
dao.deleteById(id) // cascades to payment_addresses
}
// --- Lookup (for send flow) ---
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress)
suspend fun findByLnurl(lnurl: String): ContactEntity? =
dao.findByAddress(lnurl)
// --- Contact-Tx link ---
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(paymentHash)
suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
}
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
dao.unlinkPaymentFromContact(paymentHash, contactId)
}
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
dao.observePaymentIdsForContact(contactId)
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames()
.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 findPaymentHashesByContactName(query: String): List<String> =
dao.findPaymentHashesByContactName(query)
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
dao.findPaymentHashesByContactIds(contactIds)
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity> =
dao.getContactsForPayment(paymentHash)
}
@@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.bitcointxoko.gudariwallet.api.JsonProvider
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import java.io.IOException
private const val TAG = "FiatDataStore"
@@ -27,13 +28,13 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
class FiatDataStore(context: Context) {
private val dataStore = context.applicationContext.fiatDataStore
private val gson = Gson()
private val json = JsonProvider.json
// ── Key factories ─────────────────────────────────────────────────────────
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency")
private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
// ── Rate ──────────────────────────────────────────────────────────────────
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
} else throw e
}
.first()
val json = prefs[currenciesKey] ?: return null
val jsonString = prefs[currenciesKey] ?: return null
return runCatching {
val type = object : TypeToken<List<String>>() {}.type
val list: List<String> = gson.fromJson(json, type)
val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
list
}.getOrElse { e ->
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
if (currencies.isEmpty()) return
runCatching {
dataStore.edit { prefs ->
prefs[currenciesKey] = gson.toJson(currencies)
prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
}
}.onFailure { e ->
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
@@ -1,5 +1,8 @@
package com.bitcointxoko.gudariwallet.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
private const val TAG = "FiatRateCache"
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
*/
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 ──────────────────────────────────────────────────────────────────
private var cachedRate : Double? = null
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = rate
rateLastFetchedAt = now
cachedRateCurrency = currency
_fiatFlow.value = Pair(currency, rate)
fiatDataStore.persistRate(currency, rate)
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
}
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = null
rateLastFetchedAt = 0L
cachedRateCurrency = ""
_fiatFlow.value = Pair(null, null)
}
// ── Currency list ─────────────────────────────────────────────────────────
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
}
val currentRate: Double?
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
}
@@ -286,4 +286,131 @@ class LNbitsWalletRepository(
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
}
// ── NWC Provider ───────────────────────────────────────────────────────────
/**
* Returns the map of supported NWC permission names.
*/
override suspend fun getNwcPermissions(): Map<String, Any> {
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
val result = api.getNwcPermissions(secrets.invoiceKey())
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
return result
}
/**
* Returns all NWC connection keys for the current wallet.
*/
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
return result
}
/**
* Returns a single NWC connection key by its public key.
*/
override suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean,
refreshLastUsed : Boolean
): NwcGetResponse {
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}")
val result = api.getNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey,
includeExpired = includeExpired,
refreshLastUsed = refreshLastUsed
)
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
return result
}
/**
* Registers / creates a new NWC connection key.
* Requires admin key.
*/
override suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse {
Timber.d(
"%snull", "NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} "
)
val result = api.registerNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey,
request = request
)
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
return result
}
/**
* Deletes an NWC connection key by its public key.
* Requires admin key.
*/
override suspend fun deleteNwcKey(pubkey: String) {
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}")
api.deleteNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey
)
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
}
/**
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
*/
override suspend fun getNwcPairingUrl(secret: String): String {
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}")
val url = api.getNwcPairingUrl(
invoiceKey = secrets.invoiceKey(),
secret = secret
)
Timber.i("NWC [PAIRING ] pairing URL received ✓")
return url
}
/**
* Returns all extension configuration settings.
* Requires admin key.
*/
override suspend fun getNwcConfig(): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching all config settings")
val result = api.getNwcConfig(secrets.adminKey())
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
return result
}
/**
* Returns a single configuration value by key.
* Requires admin key.
*/
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching config key='$key'")
val result = api.getNwcConfigValue(
adminKey = secrets.adminKey(),
key = key
)
Timber.i("NWC [CONFIG ] received value for key='$key'")
return result
}
/**
* Sets / updates one or more configuration values.
* Requires admin key.
*/
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
val result = api.setNwcConfig(
adminKey = secrets.adminKey(),
config = config
)
Timber.i("NWC [CONFIG SET] config updated ✓")
return result
}
}
@@ -38,8 +38,7 @@ class LightningAddressStore(private val context: Context) {
.map { prefs ->
prefs[KEY_ADDRESS].also { address ->
_currentAddress = address
Timber.d("LNADDR [CACHE ] read from cache: " +
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
}
suspend fun isCacheStale(): Boolean {
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
val age = System.currentTimeMillis() - fetchedAt
val isStale = address == null || age > LNADDRESS_TTL_MS
Timber.d("LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " +
if (isStale) "→ cache stale, will fetch from API"
Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
else "→ cache fresh, skipping API call")
isStale
}
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
import android.content.Context
import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
class LnurlCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
private val gson = GsonProvider.gson
private val json = JsonProvider.json
// Fast in-memory path — avoids a DB round-trip for keys already loaded
// this session. Mirrors the pattern used in PaymentCacheRepository.
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
return null
}
val response = deserialise(entity.responseJson) ?: run {
val response = deserialize(entity.responseJson) ?: run {
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key)
return null
@@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) {
*/
suspend fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable
if ( disposable == true) {
if (disposable == true) {
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return
}
val entity = LnurlCacheEntity(
key = key,
responseJson = gson.toJson(response),
responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
savedAt = System.currentTimeMillis()
)
runCatching {
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) }
}
private fun deserialise(json: String): LnurlScanResponse? =
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) }
private fun deserialize(jsonString: String): LnurlScanResponse? =
runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull()
}
@@ -0,0 +1,137 @@
package com.bitcointxoko.gudariwallet.data
import android.app.Application
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcKey
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.NwcBudgetEntity
import com.bitcointxoko.gudariwallet.data.db.NwcKeyDao
import com.bitcointxoko.gudariwallet.data.db.NwcKeyEntity
import kotlinx.coroutines.flow.Flow
class NwcCacheRepository(app: Application) {
private val dao: NwcKeyDao = AppDatabase.getInstance(app).nwcKeyDao()
// ── Observe (for ViewModels to collect) ───────────────────────────
fun observeKeys(): Flow<List<NwcKeyEntity>> = dao.observeAll()
fun observeKey(pubkey: String): Flow<NwcKeyEntity?> = dao.observeKey(pubkey)
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>> =
dao.observeBudgets(pubkey)
// ── Sync (called after network fetches) ───────────────────────────
// One-shot synchronous read — for startup population only
suspend fun getKeysOnce(): List<NwcGetResponse> =
dao.getAllKeys().map { it.toDomain().toGetResponse() }
/**
* Smart sync for the full key list.
*
* - New keys (not yet cached) → full insert
* - Existing keys → update last_used only (all other fields are immutable)
* - Keys present in cache but absent from server response → evict
*
* Never overwrites immutable fields on existing rows.
*/
suspend fun syncKeys(responses: List<NwcGetResponse>) {
val incoming = responses.associateBy { it.data.pubkey }
val cached = dao.getAllPubkeys().toSet()
dao.syncTransaction {
val newPubkeys = incoming.keys - cached
newPubkeys.forEach { pubkey ->
val response = incoming.getValue(pubkey)
dao.insertKey(response.data.toEntity())
dao.insertBudgets(response.budgets.map { it.toEntity() })
}
val existingPubkeys = incoming.keys intersect cached
existingPubkeys.forEach { pubkey ->
dao.updateLastUsed(pubkey, incoming.getValue(pubkey).data.last_used)
}
val removedPubkeys = cached - incoming.keys
if (removedPubkeys.isNotEmpty()) {
dao.deleteKeys(removedPubkeys)
}
}
}
/**
* Write a single key response into the cache.
* Used after registerNwcKey() — inserts the new key exactly once.
* IGNORE conflict strategy on the DAO ensures this is a no-op if
* somehow called twice for the same pubkey.
*/
suspend fun saveKey(response: NwcGetResponse) {
dao.insertKey(response.data.toEntity())
dao.insertBudgets(response.budgets.map { it.toEntity() })
}
/**
* Update only last_used for a single key.
* Called after getNwcKey(refreshLastUsed = true) on the detail screen.
*/
suspend fun refreshLastUsed(pubkey: String, lastUsed: Long) {
dao.updateLastUsed(pubkey, lastUsed)
}
/**
* Evict a key from the cache.
* Called after a successful deleteNwcKey() network call.
* CASCADE on the FK removes associated budgets automatically.
*/
suspend fun deleteKey(pubkey: String) {
dao.deleteKey(pubkey)
}
}
// ── Mappers ───────────────────────────────────────────────────────────
// Kept in this file — they exist solely to serve NwcCacheRepository.
// If mappers grow (e.g. reverse domain→entity direction is needed),
// extract to NwcEntityMappers.kt.
private fun NwcKey.toEntity(): NwcKeyEntity = NwcKeyEntity(
pubkey = pubkey,
wallet = wallet,
description = description,
expiresAt = expires_at,
permissions = permissions,
createdAt = created_at,
lastUsed = last_used,
cachedAt = System.currentTimeMillis() / 1000L
)
private fun NwcBudget.toEntity(): NwcBudgetEntity = NwcBudgetEntity(
id = id,
pubkey = pubkey,
budgetMsats = budget_msats,
refreshWindow = refresh_window,
createdAt = created_at,
usedBudgetMsats = used_budget_msats
)
fun NwcKeyEntity.toDomain(): NwcKey = NwcKey(
pubkey = pubkey,
wallet = wallet,
description = description,
expires_at = expiresAt,
permissions = permissions,
created_at = createdAt,
last_used = lastUsed
)
fun NwcBudgetEntity.toDomain(): NwcBudget = NwcBudget(
id = id,
pubkey = pubkey,
budget_msats = budgetMsats,
refresh_window = refreshWindow,
created_at = createdAt,
used_budget_msats = usedBudgetMsats
)
fun NwcKey.toGetResponse() = NwcGetResponse(data = this, budgets = emptyList())
@@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
import kotlinx.coroutines.flow.Flow
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentCacheRepository"
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return
val now = System.currentTimeMillis()
runCatching {
dao.insertAll(payments.map { it.toEntity(now) })
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
payments.forEach { payment ->
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 {
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
}
}
/** Persist the first page of payments to the database. */
suspend fun savePayments(payments: List<PaymentRecord>) {
val now = System.currentTimeMillis()
@@ -109,12 +132,14 @@ class PaymentCacheRepository(context: Context) {
minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt
).map { it.toDomain() }.also {
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} " +
"date=${filter.minCreatedAt}${filter.maxCreatedAt}${it.size} rows")
Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} ")
}
}
/** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */
fun observeAll(): Flow<List<PaymentRecordEntity>> = dao.observeAll()
// ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */
@@ -131,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
/** Upsert PaymentRecord to database. */
suspend fun upsertPayment(payment: PaymentRecord) {
val entity = payment.toEntity(savedAt = System.currentTimeMillis())
dao.insert(entity)
val e = payment.toEntity(savedAt = System.currentTimeMillis())
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}")
}
@@ -160,4 +201,34 @@ class PaymentCacheRepository(context: Context) {
dao.deleteAll()
Timber.d("PAYMENTS [CLEARED ] cache wiped")
}
/**
* Fetch payments by checkingId with the full PaymentFilter applied.
* Used by HistoryViewModel for contact name search supplement and
* 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()
}
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
@@ -41,4 +43,20 @@ interface WalletRepository {
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String>
suspend fun getNwcPermissions(): Map<String, Any>
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean = false,
refreshLastUsed : Boolean = false
): NwcGetResponse
suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse
suspend fun deleteNwcKey(pubkey: String)
suspend fun getNwcPairingUrl(secret: String): String
suspend fun getNwcConfig(): Map<String, Any>
suspend fun getNwcConfigValue(key: String): Map<String, Any>
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
}
@@ -13,8 +13,13 @@ import androidx.sqlite.db.SupportSQLiteDatabase
PaymentRecordEntity::class,
LnurlCacheEntity::class,
NodeAliasCacheEntity::class,
NwcKeyEntity::class,
NwcBudgetEntity::class,
ContactEntity::class,
PaymentAddressEntity::class,
TxContactCrossRef::class,
],
version = 6,
version = 10,
exportSchema = false
)
@TypeConverters(PaymentConverters::class)
@@ -23,6 +28,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao
abstract fun contactDao(): ContactDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
@@ -86,6 +93,132 @@ abstract class AppDatabase : RoomDatabase() {
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS nwc_keys (
pubkey TEXT NOT NULL PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
expires_at INTEGER NOT NULL,
permissions TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
cached_at INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS nwc_budgets (
id INTEGER NOT NULL PRIMARY KEY,
pubkey TEXT NOT NULL,
budget_msats INTEGER NOT NULL,
refresh_window INTEGER NOT NULL,
created_at INTEGER NOT NULL,
used_budget_msats INTEGER NOT NULL,
FOREIGN KEY(pubkey) REFERENCES nwc_keys(pubkey)
ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_nwc_budgets_pubkey
ON nwc_budgets(pubkey)
""".trimIndent())
}
}
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS contacts (
id TEXT NOT NULL PRIMARY KEY,
localAlias TEXT,
displayName TEXT,
name TEXT,
pictureUrl TEXT,
nostrPubkey TEXT,
nip05 TEXT,
about TEXT,
lnAddress TEXT,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
ON contacts(nostrPubkey)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
ON contacts(lnAddress)
""".trimIndent())
db.execSQL("""
CREATE TABLE IF NOT EXISTS payment_addresses (
id TEXT NOT NULL PRIMARY KEY,
contactId TEXT NOT NULL,
type TEXT NOT NULL,
address TEXT NOT NULL,
label TEXT,
isDefault INTEGER NOT NULL DEFAULT 0,
addedAt INTEGER NOT NULL,
FOREIGN KEY(contactId) REFERENCES contacts(id)
ON DELETE CASCADE
)
""".trimIndent())
db.execSQL("""
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
ON payment_addresses(contactId)
""".trimIndent())
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS tx_contact_links (
checkingId TEXT NOT NULL,
contactId TEXT NOT NULL,
role TEXT,
createdAt INTEGER NOT NULL,
PRIMARY KEY (checkingId, contactId),
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
}
}
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 =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
@@ -93,7 +226,17 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"gudari_wallet.db"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10
)
.build()
.also { INSTANCE = it }
}
@@ -0,0 +1,168 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.*
import kotlinx.coroutines.flow.Flow
data class ContactWithAddresses(
@Embedded val contact: ContactEntity,
@Relation(
parentColumn = "id",
entityColumn = "contactId"
)
val addresses: List<PaymentAddressEntity>
)
/** Flat projection used by PaymentFeedRepository to build ContactSummary lists. */
data class PaymentContactSummary(
val paymentHash: String,
val contactId: String,
val displayName: String
)
@Dao
interface ContactDao {
// --- Contacts ---
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAll(): Flow<List<ContactEntity>>
@Transaction
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
@Transaction
@Query("SELECT * FROM contacts WHERE id = :id")
fun observeById(id: String): Flow<ContactWithAddresses?>
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): ContactEntity?
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
suspend fun findByAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
suspend fun findByLnAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(contact: ContactEntity)
@Update
suspend fun update(contact: ContactEntity)
@Query("DELETE FROM contacts WHERE id = :id")
suspend fun deleteById(id: String)
// --- Payment addresses ---
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAddress(address: PaymentAddressEntity)
@Query("""
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")
suspend fun deleteAddressById(id: String)
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
// Clear the isDefault flag for a type before setting a new default
@Query("""
UPDATE payment_addresses
SET isDefault = 0
WHERE contactId = :contactId AND type = :type
""")
suspend fun clearDefault(contactId: String, type: String)
// Insert a link
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
// Delete all contact links for a payment
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
suspend fun clearContactsForPayment(paymentHash: String)
// Get all contacts linked to a payment
@Query("""
SELECT c.* FROM contacts c
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
WHERE tcl.paymentHash = :paymentHash
""")
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact
@Query("""
SELECT paymentHash FROM tx_contact_links
WHERE contactId = :contactId
ORDER BY createdAt DESC
""")
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// Returns all (paymentHash, displayName) pairs that have a linked contact,
// so HistoryViewModel can build its lookup map in a single reactive query.
@Query("""
SELECT tcl.paymentHash AS paymentHash,
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
""")
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// Returns all (paymentHash, contactId, displayName) triples — used by
// PaymentFeedRepository to build full ContactSummary lists per payment.
@Query("""
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
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""")
suspend fun findPaymentHashesByContactName(query: String): List<String>
// For filter-by-contact — returns paymentHashs for specific contact IDs
@Query("""
SELECT DISTINCT paymentHash FROM tx_contact_links
WHERE contactId IN (:contactIds)
""")
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>
}
@@ -0,0 +1,36 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "contacts",
indices = [
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
Index(value = ["lnAddress"])
]
)
data class ContactEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
// --- Display identity ---
val localAlias: String?, // user-set name — highest display priority
val displayName: String?, // future: from nostr kind 0 display_name
val name: String?, // future: from nostr kind 0 name
val pictureUrl: String?, // future: from nostr kind 0 picture
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
val nostrPubkey: String?, // 32-byte hex; unique index
val nip05: String?, // e.g. user@domain.com
val about: String?, // bio / notes
// --- Payment fast-access (denormalized for single-row lookup) ---
val lnAddress: String?, // primary Lightning Address (lud16)
// --- Metadata ---
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,33 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "nwc_budgets",
foreignKeys = [ForeignKey(
entity = NwcKeyEntity::class,
parentColumns = ["pubkey"],
childColumns = ["pubkey"],
onDelete = ForeignKey.CASCADE // auto-cleans budgets when key is deleted
)],
indices = [Index("pubkey")]
)
data class NwcBudgetEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Int,
@ColumnInfo(name = "pubkey")
val pubkey: String,
@ColumnInfo(name = "budget_msats")
val budgetMsats: Long,
@ColumnInfo(name = "refresh_window")
val refreshWindow: Int,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "used_budget_msats")
val usedBudgetMsats: Long
)
@@ -0,0 +1,97 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface NwcKeyDao {
// ── Observe (UI layer subscriptions) ──────────────────────────────
/**
* Live stream of all cached keys, newest first.
* Room re-emits automatically on any write — this is the only
* channel the list screen needs to collect.
*/
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
fun observeAll(): Flow<List<NwcKeyEntity>>
/**
* Live stream of a single key — for the detail screen.
* Emits null if the key has been evicted (e.g. after a delete).
*/
@Query("SELECT * FROM nwc_keys WHERE pubkey = :pubkey")
fun observeKey(pubkey: String): Flow<NwcKeyEntity?>
/**
* Live stream of budgets for a given key — for the detail screen.
* CASCADE delete on the parent ensures this automatically becomes
* empty when the parent key is deleted.
*/
@Query("SELECT * FROM nwc_budgets WHERE pubkey = :pubkey")
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>>
// ── One-shot reads (sync logic, not UI) ───────────────────────────
/**
* Lightweight membership check used by syncKeys() to determine
* which incoming keys are new vs. already cached.
* Returns only pubkeys — avoids loading full rows unnecessarily.
*/
@Query("SELECT pubkey FROM nwc_keys")
suspend fun getAllPubkeys(): List<String>
/**
* One-shot full read of all cached keys — used for instant startup
* population before the Flow subscription is active.
* Same query as observeAll() but returns a plain list instead of Flow.
*/
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
suspend fun getAllKeys(): List<NwcKeyEntity>
// ── Writes ────────────────────────────────────────────────────────
/**
* Insert a brand-new key. IGNORE on conflict means an existing row
* is never overwritten by this path — all immutable fields are safe.
* Used exclusively when a key is first seen (creation or first sync).
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertKey(key: NwcKeyEntity)
@Transaction
suspend fun syncTransaction(block: suspend () -> Unit) = block()
/**
* Insert budgets for a new key. Also IGNORE on conflict —
* budget fields are immutable post-creation.
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertBudgets(budgets: List<NwcBudgetEntity>)
/**
* Update ONLY last_used — the single mutable field on a key.
* This is the only update path that should ever run post-creation.
*/
@Query("UPDATE nwc_keys SET last_used = :lastUsed WHERE pubkey = :pubkey")
suspend fun updateLastUsed(pubkey: String, lastUsed: Long)
/**
* Evict a key by pubkey. The CASCADE foreign key on nwc_budgets
* automatically deletes all associated budget rows — no manual
* budget cleanup needed.
*/
@Query("DELETE FROM nwc_keys WHERE pubkey = :pubkey")
suspend fun deleteKey(pubkey: String)
/**
* Evict a set of keys in one statement — used by syncKeys() to
* remove server-deleted keys without issuing N individual deletes.
*/
@Query("DELETE FROM nwc_keys WHERE pubkey IN (:pubkeys)")
suspend fun deleteKeys(pubkeys: Collection<String>)
}
@@ -0,0 +1,26 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "nwc_keys")
data class NwcKeyEntity(
@PrimaryKey
@ColumnInfo(name = "pubkey")
val pubkey: String,
@ColumnInfo(name = "wallet")
val wallet: String,
@ColumnInfo(name = "description")
val description: String,
@ColumnInfo(name = "expires_at")
val expiresAt: Long,
@ColumnInfo(name = "permissions")
val permissions: String, // stored as comma-separated string
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "last_used")
val lastUsed: Long,
@ColumnInfo(name = "cached_at")
val cachedAt: Long // epoch-seconds, used for staleness check if desired
)
@@ -0,0 +1,42 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "payment_addresses",
foreignKeys = [
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("contactId")]
)
data class PaymentAddressEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val contactId: String, // FK → contacts.id
val type: String, // PaymentAddressType.name (stored as String)
val address: String, // the raw address value
val label: String? = null, // e.g. "personal", "savings"
val isDefault: Boolean = false,
val addedAt: Long = System.currentTimeMillis()
)
// Keep as a simple enum in the same file or a separate file
enum class PaymentAddressType {
LIGHTNING_ADDRESS, // user@domain.com (lud16)
LNURL, // bech32 lnurl (lud06)
ON_CHAIN, // Bitcoin address
SILENT_PAYMENT, // BIP-352 sp1...
BOLT12_OFFER, // lno...
BIP353 // ₿user@domain.com
}
@@ -0,0 +1,6 @@
package com.bitcointxoko.gudariwallet.data.db
data class PaymentContactName(
val paymentHash : String,
val contactName : String
)
@@ -1,19 +1,19 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.TypeConverter
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.GsonProvider
object PaymentConverters {
private val gson = GsonProvider.gson
private val json = JsonProvider.json
@TypeConverter
@JvmStatic
fun fromExtra(extra: PaymentExtra?): String? =
extra?.let { gson.toJson(it) }
extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
@TypeConverter
@JvmStatic
fun toExtra(json: String?): PaymentExtra? =
json?.let { gson.fromJson(it, PaymentExtra::class.java) }
fun toExtra(string: String?): PaymentExtra? =
string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
}
@@ -4,6 +4,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface PaymentDao {
@@ -13,10 +15,53 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
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. */
@Query("SELECT * FROM payment_records ORDER BY time DESC")
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. */
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
@@ -32,6 +77,30 @@ interface PaymentDao {
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
@Query("""
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")
suspend fun deleteAll()
@@ -1,12 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db
import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentMappers"
private val json = JsonProvider.json
// ── Parse helper — runs once at write time, never again ──────────────────────
@@ -36,10 +36,10 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
bolt11 = bolt11,
pending = pending,
preimage = preimage,
extra = extra?.let { gson.toJson(it) },
extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
savedAt = savedAt,
pubkey = pubkey,
alias = alias
pubkey = pubkey,
alias = alias
)
// ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
bolt11 = bolt11,
pending = pending,
preimage = preimage,
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) },
pubkey = pubkey,
alias = alias
extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
pubkey = pubkey,
alias = alias
)
@@ -0,0 +1,16 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "tx_contact_links",
primaryKeys = ["paymentHash", "contactId"]
)
data class TxContactCrossRef(
val paymentHash : String,
val contactId : String,
val role : String? = null,
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,
)
}
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings
import kotlin.String
@LyricistStrings(languageTag = "en", default = true)
val EnHomeStrings = AppStrings(
@@ -55,61 +54,8 @@ val EnHomeStrings = AppStrings(
details = "Details",
enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Receive",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning address QR code",
lightningAddressLabel = "Lightning Address",
shareLightningAddress = "Share Lightning Address",
lightningInvoiceQrCode = "Lightning invoice QR code",
invoiceLabel = "Invoice",
shareInvoice = "Share Invoice",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Amount (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Memo (optional)",
memoPlaceholder = "What's this for?",
creatingInvoice = "Creating invoice…",
createInvoice = "Create Invoice",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Waiting…",
invoiceExpired = "Invoice expired",
newInvoice = "New Invoice",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Withdraw from",
minimumSats = { min -> "Minimum is $min sats" },
maximumSats = { max -> "Maximum is $max sats" },
withdrawButton = "Withdraw",
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Payment Received!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reduce brightness",
brightnessBoost = "Boost brightness",
qrShare = "Share",
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
today = "today",
yesterday = "yesterday",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Send",
@@ -196,118 +142,6 @@ val EnHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Activity",
historyClose = "Close",
historySearchPayments = "Search payments",
historyFilterPayments = "Filter payments",
historyRetry = "Retry",
historyNoPayments = "No payments yet.",
historySearchPlaceholder = "Search transactions",
historySearchHelp = "Search help",
historySearchPaste = "Paste",
historySearchClear = "Clear search",
historySearchChip = { tail -> "search: …$tail" },
historyClearSearch = "Clear search",
filterDirectionOutgoing = "Outgoing",
filterDirectionIncoming = "Incoming",
filterClearDirection = "Clear direction filter",
filterStatusCompleted = "Completed",
filterStatusPending = "Pending",
filterStatusFailed = "Failed",
filterRemoveStatus = "Remove status filter",
filterRemoveType = "Remove type filter",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Clear amount filter",
filterDateThisWeek = "This week",
filterDateThisMonth = "This month",
filterDateThisYear = "This year",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "From $from" },
filterDateUntil = { until -> "Until $until" },
filterClearDate = "Clear date filter",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filter payments",
filterSectionDirection = "Direction",
filterDirectionAll = "All",
filterSectionStatus = "Status",
filterSectionType = "Type",
filterSectionAmount = "Amount (sats)",
filterAmountMin_label = "Min",
filterAmountMax_label = "Max",
filterAmountSuffix = "sats",
filterSectionDate = "Date",
filterDateFrom_label = "From",
filterDateTo_label = "To",
filterDialogOk = "OK",
filterDialogCancel = "Cancel",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "What can I search for?",
searchInfoSubtitle = "Type any part of the following to find a payment:",
searchInfoMemoTitle = "Memo",
searchInfoMemoDesc = "The message or description attached to a payment. " +
"For example, \"coffee\" or \"rent March\".",
searchInfoInvoiceTitle = "Invoice",
searchInfoInvoiceDesc = "The payment request you scanned or pasted to send a payment. " +
"It usually starts with \"lnbc…\". " +
"You can paste just the first or last few characters.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "A unique code that identifies this payment on the " +
"Lightning Network. Useful if someone asks you to " +
"confirm a specific transaction.",
searchInfoPreimageTitle = "Preimage",
searchInfoPreimageDesc = "A secret code that proves a payment was received. " +
"Only available after a payment completes successfully.",
searchInfoNodeAliasTitle = "Node alias",
searchInfoNodeAliasDesc = "The name of the Lightning node you sent a payment to. " +
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
"Aliases are resolved automatically for outgoing payments.",
searchInfoNodeIdTitle = "Node ID (pubkey)",
searchInfoNodeIdDesc = "The public key of the destination node. " +
"A 66-character hex string — you can paste just the first few characters.",
searchInfoFooter = "You can paste a full value or search with just a few characters — " +
"partial matches work too.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Success", // e.g. "Success"
statusPending = "Pending", // e.g. "Pending"
statusFailed = "Failed", // e.g. "Failed"
statusUnknown = "Unknown", // e.g. "Unknown"
detailOutgoingTitle = "Outgoing payment",
detailIncomingTitle = "Incoming payment",
detailBack = "Back",
detailCouldNotLoad = "Could not load full details",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Fee: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Details",
detailRowDate = "Date",
detailRowMemo = "Memo",
detailRowMemoEmpty = "",
detailRowType = "Type",
detailRowComment = "Comment",
detailSectionSuccessAction = "Success Action",
detailSuccessMessage = "Message",
detailSuccessUrl = "URL",
detailSuccessDescription = "Description",
detailSectionTechnical = "Technical",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimage",
detailRowDestination = "Destination",
detailCopyNodeId = "Copy node ID",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Sent",
paymentRowReceivedCD = "Received",
paymentRowNoMemo = "No memo",
paymentRowPending = "Pending",
paymentRowFailed = "Failed",
onboarding = OnboardingStrings(
back = "Back",
next = "Next",
@@ -325,6 +159,7 @@ val EnHomeStrings = AppStrings(
hideAdminKey = "Hide",
showAdminKey = "Show",
fieldsRequired = "All fields are required.",
invalidKeyFormat = "Invalid key format",
connect = "Connect",
notificationsTitle = "Stay in the loop",
notificationsSubtitle = "Get notified instantly when you receive a payment.",
@@ -352,6 +187,301 @@ val EnHomeStrings = AppStrings(
scheduleSync = "Schedule sync",
schedule = "Schedule",
cancel = "Cancel"
)
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Receive",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning address QR code",
lightningAddressLabel = "Lightning Address",
shareLightningAddress = "Share Lightning Address",
lightningInvoiceQrCode = "Lightning invoice QR code",
invoiceLabel = "Invoice",
shareInvoice = "Share Invoice",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Amount (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Memo (optional)",
memoPlaceholder = "What's this for?",
creatingInvoice = "Creating invoice…",
createInvoice = "Create Invoice",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Waiting…",
invoiceExpired = "Invoice expired",
newInvoice = "New Invoice",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Withdraw from",
minimumSats = { min -> "Minimum is $min sats" },
maximumSats = { max -> "Maximum is $max sats" },
withdrawButton = "Withdraw",
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Payment Received!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reduce brightness",
brightnessBoost = "Boost brightness",
qrShare = "Share",
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
openInApp = "Open in app",
noAppFound = "No compatible app found",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
),
history = HistoryStrings(
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Activity",
historyClose = "Close",
historySearchPayments = "Search payments",
historyFilterPayments = "Filter payments",
historyRetry = "Retry",
historyNoPayments = "No payments yet.",
historySearchPlaceholder = "Search transactions",
historySearchHelp = "Search help",
historySearchPaste = "Paste",
historySearchClear = "Clear search",
historySearchChip = { tail -> "search: …$tail" },
historyClearSearch = "Clear search",
filterDirectionOutgoing = "Outgoing",
filterDirectionIncoming = "Incoming",
filterClearDirection = "Clear direction filter",
filterStatusCompleted = "Completed",
filterStatusPending = "Pending",
filterStatusFailed = "Failed",
filterRemoveStatus = "Remove status filter",
filterRemoveType = "Remove type filter",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Clear amount filter",
filterDateThisWeek = "This week",
filterDateThisMonth = "This month",
filterDateThisYear = "This year",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "From $from" },
filterDateUntil = { until -> "Until $until" },
filterClearDate = "Clear date filter",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filter payments",
filterSectionDirection = "Direction",
filterDirectionAll = "All",
filterSectionStatus = "Status",
filterSectionType = "Type",
filterSectionAmount = "Amount (sats)",
filterAmountMin_label = "Min",
filterAmountMax_label = "Max",
filterAmountSuffix = "sats",
filterSectionDate = "Date",
filterDateFrom_label = "From",
filterDateTo_label = "To",
filterDialogOk = "OK",
filterDialogCancel = "Cancel",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "What can I search for?",
searchInfoSubtitle = "Type any part of the following to find a payment:",
searchInfoMemoTitle = "Memo",
searchInfoMemoDesc = "The message or description attached to a payment. " +
"For example, \"coffee\" or \"rent March\".",
searchInfoInvoiceTitle = "Invoice",
searchInfoInvoiceDesc = "The payment request you scanned or pasted to send a payment. " +
"It usually starts with \"lnbc…\". " +
"You can paste just the first or last few characters.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "A unique code that identifies this payment on the " +
"Lightning Network. Useful if someone asks you to " +
"confirm a specific transaction.",
searchInfoPreimageTitle = "Preimage",
searchInfoPreimageDesc = "A secret code that proves a payment was received. " +
"Only available after a payment completes successfully.",
searchInfoNodeAliasTitle = "Node alias",
searchInfoNodeAliasDesc = "The name of the Lightning node you sent a payment to. " +
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
"Aliases are resolved automatically for outgoing payments.",
searchInfoNodeIdTitle = "Node ID (pubkey)",
searchInfoNodeIdDesc = "The public key of the destination node. " +
"A 66-character hex string — you can paste just the first few characters.",
searchInfoFooter = "You can paste a full value or search with just a few characters — " +
"partial matches work too.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Success", // e.g. "Success"
statusPending = "Pending", // e.g. "Pending"
statusFailed = "Failed", // e.g. "Failed"
statusUnknown = "Unknown", // e.g. "Unknown"
detailOutgoingTitle = "Outgoing payment",
detailIncomingTitle = "Incoming payment",
detailBack = "Back",
detailCouldNotLoad = "Could not load full details",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Fee: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Details",
detailRowDate = "Date",
detailRowMemo = "Memo",
detailRowMemoEmpty = "",
detailRowType = "Type",
detailRowComment = "Comment",
detailSectionSuccessAction = "Success Action",
detailSuccessMessage = "Message",
detailSuccessUrl = "URL",
detailSuccessDescription = "Description",
detailSectionTechnical = "Technical",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimage",
detailRowDestination = "Destination",
detailCopyNodeId = "Copy node ID",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Sent",
paymentRowReceivedCD = "Received",
paymentRowNoMemo = "No memo",
paymentRowPending = "Pending",
paymentRowFailed = "Failed",
),
nwc = NwcStrings(
back = "Back",
next = "Next",
done = "Done",
cancel = "Cancel",
schedule = "Schedule",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Connection",
connectionUnknownWallet = "Unknown app",
connectionSectionDetails = "Details",
connectionRowCreated = "Connected",
connectionRowLastUsed = "Last used",
connectionRowExpires = "Expires",
connectionRowPubkey = "Public key",
connectionNeverUsed = "Never",
lastUsedToday = "Last used today",
lastUsedYesterday = "Last used yesterday",
lastUsedDate = "Last used %s",
connectionNoExpiry = "Never",
connectionExpired = { date -> "Expired · $date" },
connectionSectionPermissions = "Permissions",
connectionSectionBudgets = "Spending budgets",
connectionBudgetTitle = { window -> "$window budget" },
connectionBudgetUsed = { sats -> "Used: $sats sats" },
connectionBudgetTotal = { sats -> "of $sats sats" },
connectionRevoke = "Revoke connection",
connectionStatusActive = "Active",
connectionStatusExpired = "Expired",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pay invoices",
permGetBalance = "Read balance",
permGetInfo = "Read wallet info",
permMakeInvoice = "Create invoices",
permLookupInvoice = "Look up invoices",
permListTransactions = "View transaction history",
permSignMessage = "Sign messages",
permPay = "Send payments",
permInvoice = "Create invoices",
permLookup = "Lookup invoice status",
permHistory = "Read transaction history",
permBalance = "Read wallet balance",
permInfo = "Read account info",
permLookupHistory = "Lookup status & transaction history",
permBalanceInfo = "Read balance & account info",
permSendPayments = "Send payments",
permCreateInvoices = "Create invoices",
permLookupInvoiceStatus = "Lookup status of invoice",
permReadTransactionHistory = "Read transaction history",
permReadWalletBalance = "Read wallet balance",
permReadAccountInfo = "Read account info",
revokeDialogTitle = "Revoke connection?",
revokeDialogBody = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
revokeDialogConfirm = "Revoke",
revokeDialogDismiss = "Cancel",
refreshDaily = "Daily",
refreshWeekly = "Weekly",
refreshMonthly = "Monthly",
refreshYearly = "Yearly",
refreshNever = "Never",
newConnection = "New connection",
labelHint = "Label (e.g. Amethyst, Bitrefill)",
expires = "Expires",
pickDate = "Pick date",
pickTime = "Pick time",
never = "Never",
budgets = "Budgets",
addBudget = "Add budget",
removeBudget = "Remove budget",
permissions = "Permissions",
unnamed = "Unnamed",
creating = "Creating…",
createConnection = "Create connection",
ok = "OK",
sats = "Sats",
resets = "Resets",
deleteConnection = "Delete connection",
unnamedConnection = "Unnamed connection",
neverUsed = "Never used",
expired = "Expired",
connectionCreated = "Connection created",
scanOrCopyHint = "Scan or copy this connection string into your app. It will not be shown again.",
nwcPairingQrCode = "NWC pairing QR code",
nwcConnectionString = "NWC connection string",
shareNwcConnectionString = "Share NWC connection string",
openInApp = "Open in app",
walletConnect = "Wallet Connect",
addConnection = "Add connection",
noCompatibleAppFound = "No compatible app found",
removeConnection = "Remove connection?",
removeConnectionDetail = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
remove = "Remove",
noConnectionsYet = "No connections yet",
tapToAddHint = "Tap + to connect an app via Nostr Wallet Connect.",
somethingWentWrong = "Something went wrong",
retry = "Retry",
failedToLoadConnections = "Failed to load connections",
failedToCreateConnection = "Failed to create connection",
failedToDeleteConnection = "Failed to delete connection",
failedToLoadConnection = "Failed to load connection",
),
lock = LockStrings(
appLockTitle = "App Lock",
appLockDescription = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
appLockToggleLabel = "Lock app on open",
appLockToggleEnabled = "Enabled (recommended)",
appLockToggleDisabled = "Disabled",
appName = "Gudari Wallet",
authenticateToContinue = "Authenticate to continue",
unlockButton = "Unlock",
biometricPromptTitle = "Unlock Gudari Wallet",
biometricPromptSubtitle = "Authenticate to access your wallet",
),
)
@@ -54,61 +54,8 @@ val EsHomeStrings = AppStrings(
details = "Detalles",
enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Recibir",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Código QR de dirección Lightning",
lightningAddressLabel = "Dirección Lightning",
shareLightningAddress = "Compartir dirección Lightning",
lightningInvoiceQrCode = "Código QR de factura Lightning",
invoiceLabel = "Factura",
shareInvoice = "Compartir factura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Cantidad (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Nota (opcional)",
memoPlaceholder = "¿Para qué es esto?",
creatingInvoice = "Creando factura…",
createInvoice = "Crear factura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Esperando…",
invoiceExpired = "Factura expirada",
newInvoice = "Nueva factura",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Retirar de",
minimumSats = { min -> "Mínimo es $min sats" },
maximumSats = { max -> "Máximo es $max sats" },
withdrawButton = "Retirar",
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "¡Pago recibido!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reducir brillo",
brightnessBoost = "Aumentar brillo",
qrShare = "Compartir",
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
today = "hoy",
yesterday = "ayer",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar",
@@ -195,119 +142,6 @@ val EsHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historial",
historyClose = "Cerrar",
historySearchPayments = "Buscar pagos",
historyFilterPayments = "Filtrar pagos",
historyRetry = "Reintentar",
historyNoPayments = "Aún no hay pagos.",
historySearchPlaceholder = "Buscar transacciones",
historySearchHelp = "Ayuda de búsqueda",
historySearchPaste = "Pegar",
historySearchClear = "Limpiar búsqueda",
historySearchChip = { tail -> "búsqueda: …$tail" },
historyClearSearch = "Limpiar búsqueda",
filterDirectionOutgoing = "Salientes",
filterDirectionIncoming = "Entrantes",
filterClearDirection = "Quitar filtro de dirección",
filterStatusCompleted = "Completado",
filterStatusPending = "Pendiente",
filterStatusFailed = "Fallido",
filterRemoveStatus = "Quitar filtro de estado",
filterRemoveType = "Quitar filtro de tipo",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Quitar filtro de cantidad",
filterDateThisWeek = "Esta semana",
filterDateThisMonth = "Este mes",
filterDateThisYear = "Este año",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "Desde $from" },
filterDateUntil = { until -> "Hasta $until" },
filterClearDate = "Quitar filtro de fecha",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filtrar pagos",
filterSectionDirection = "Dirección",
filterDirectionAll = "Todos",
filterSectionStatus = "Estado",
filterSectionType = "Tipo",
filterSectionAmount = "Cantidad (sats)",
filterAmountMin_label = "Mín.",
filterAmountMax_label = "Máx.",
filterAmountSuffix = "sats",
filterSectionDate = "Fecha",
filterDateFrom_label = "Desde",
filterDateTo_label = "Hasta",
filterDialogOk = "Aceptar",
filterDialogCancel = "Cancelar",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "¿Qué puedo buscar?",
searchInfoSubtitle = "Escribe cualquier parte de lo siguiente para encontrar un pago:",
searchInfoMemoTitle = "Nota",
searchInfoMemoDesc = "El mensaje o descripción adjunto a un pago. " +
"Por ejemplo, \"café\" o \"alquiler marzo\".",
searchInfoInvoiceTitle = "Factura",
searchInfoInvoiceDesc = "La solicitud de pago que escaneaste o pegaste para enviar un pago. " +
"Normalmente empieza por \"lnbc…\". " +
"Puedes pegar solo los primeros o últimos caracteres.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "Un código único que identifica este pago en la " +
"red Lightning. Útil si alguien te pide que " +
"confirmes una transacción específica.",
searchInfoPreimageTitle = "Preimagen",
searchInfoPreimageDesc = "Un código secreto que demuestra que se recibió un pago. " +
"Solo disponible después de que un pago se complete correctamente.",
searchInfoNodeAliasTitle = "Alias del nodo",
searchInfoNodeAliasDesc = "El nombre del nodo Lightning al que enviaste un pago. " +
"Por ejemplo, \"ACINQ\" o \"Wallet of Satoshi\". " +
"Los alias se resuelven automáticamente para pagos salientes.",
searchInfoNodeIdTitle = "ID del nodo (clave pública)",
searchInfoNodeIdDesc = "La clave pública del nodo de destino. " +
"Una cadena hexadecimal de 66 caracteres — puedes pegar solo los primeros.",
searchInfoFooter = "Puedes pegar un valor completo o buscar con solo unos pocos caracteres — " +
"las coincidencias parciales también funcionan.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Completado",
statusPending = "Pendiente",
statusFailed = "Fallido",
statusUnknown = "Desconocido",
detailOutgoingTitle = "Pago saliente",
detailIncomingTitle = "Pago entrante",
detailBack = "Atrás",
detailCouldNotLoad = "No se pudieron cargar los detalles completos",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Comisión: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Detalles",
detailRowDate = "Fecha",
detailRowMemo = "Nota",
detailRowMemoEmpty = "",
detailRowType = "Tipo",
detailRowComment = "Comentario",
detailSectionSuccessAction = "Acción de éxito",
detailSuccessMessage = "Mensaje",
detailSuccessUrl = "URL",
detailSuccessDescription = "Descripción",
detailSectionTechnical = "Técnico",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimagen",
detailRowDestination = "Destino",
detailCopyNodeId = "Copiar ID del nodo",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Enviado",
paymentRowReceivedCD = "Recibido",
paymentRowNoMemo = "Sin nota",
paymentRowPending = "Pendiente",
paymentRowFailed = "Fallido",
onboarding = OnboardingStrings(
back = "Atrás",
next = "Siguiente",
@@ -325,6 +159,7 @@ val EsHomeStrings = AppStrings(
hideAdminKey = "Ocultar",
showAdminKey = "Mostrar",
fieldsRequired = "Todos los campos son obligatorios.",
invalidKeyFormat = "Formato de clave no válido",
connect = "Conectar",
notificationsTitle = "Mantente al día",
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
@@ -352,5 +187,300 @@ val EsHomeStrings = AppStrings(
scheduleSync = "Programar sincronización",
schedule = "Programar",
cancel = "Cancelar"
)
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Recibir",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Error",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Código QR de dirección Lightning",
lightningAddressLabel = "Dirección Lightning",
shareLightningAddress = "Compartir dirección Lightning",
lightningInvoiceQrCode = "Código QR de factura Lightning",
invoiceLabel = "Factura",
shareInvoice = "Compartir factura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Cantidad (sats)",
satsRange = { min, max -> "$min $max sats" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Nota (opcional)",
memoPlaceholder = "¿Para qué es esto?",
creatingInvoice = "Creando factura…",
createInvoice = "Crear factura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Esperando…",
invoiceExpired = "Factura expirada",
newInvoice = "Nueva factura",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Retirar de",
minimumSats = { min -> "Mínimo es $min sats" },
maximumSats = { max -> "Máximo es $max sats" },
withdrawButton = "Retirar",
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "¡Pago recibido!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reducir brillo",
brightnessBoost = "Aumentar brillo",
qrShare = "Compartir",
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
openInApp = "Abrir en app",
noAppFound = "No hay aplicación compatible",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
),
history = HistoryStrings(
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historial",
historyClose = "Cerrar",
historySearchPayments = "Buscar pagos",
historyFilterPayments = "Filtrar pagos",
historyRetry = "Reintentar",
historyNoPayments = "Aún no hay pagos.",
historySearchPlaceholder = "Buscar transacciones",
historySearchHelp = "Ayuda de búsqueda",
historySearchPaste = "Pegar",
historySearchClear = "Limpiar búsqueda",
historySearchChip = { tail -> "búsqueda: …$tail" },
historyClearSearch = "Limpiar búsqueda",
filterDirectionOutgoing = "Salientes",
filterDirectionIncoming = "Entrantes",
filterClearDirection = "Quitar filtro de dirección",
filterStatusCompleted = "Completado",
filterStatusPending = "Pendiente",
filterStatusFailed = "Fallido",
filterRemoveStatus = "Quitar filtro de estado",
filterRemoveType = "Quitar filtro de tipo",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Quitar filtro de cantidad",
filterDateThisWeek = "Esta semana",
filterDateThisMonth = "Este mes",
filterDateThisYear = "Este año",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "Desde $from" },
filterDateUntil = { until -> "Hasta $until" },
filterClearDate = "Quitar filtro de fecha",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filtrar pagos",
filterSectionDirection = "Dirección",
filterDirectionAll = "Todos",
filterSectionStatus = "Estado",
filterSectionType = "Tipo",
filterSectionAmount = "Cantidad (sats)",
filterAmountMin_label = "Mín.",
filterAmountMax_label = "Máx.",
filterAmountSuffix = "sats",
filterSectionDate = "Fecha",
filterDateFrom_label = "Desde",
filterDateTo_label = "Hasta",
filterDialogOk = "Aceptar",
filterDialogCancel = "Cancelar",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "¿Qué puedo buscar?",
searchInfoSubtitle = "Escribe cualquier parte de lo siguiente para encontrar un pago:",
searchInfoMemoTitle = "Nota",
searchInfoMemoDesc = "El mensaje o descripción adjunto a un pago. " +
"Por ejemplo, \"café\" o \"alquiler marzo\".",
searchInfoInvoiceTitle = "Factura",
searchInfoInvoiceDesc = "La solicitud de pago que escaneaste o pegaste para enviar un pago. " +
"Normalmente empieza por \"lnbc…\". " +
"Puedes pegar solo los primeros o últimos caracteres.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "Un código único que identifica este pago en la " +
"red Lightning. Útil si alguien te pide que " +
"confirmes una transacción específica.",
searchInfoPreimageTitle = "Preimagen",
searchInfoPreimageDesc = "Un código secreto que demuestra que se recibió un pago. " +
"Solo disponible después de que un pago se complete correctamente.",
searchInfoNodeAliasTitle = "Alias del nodo",
searchInfoNodeAliasDesc = "El nombre del nodo Lightning al que enviaste un pago. " +
"Por ejemplo, \"ACINQ\" o \"Wallet of Satoshi\". " +
"Los alias se resuelven automáticamente para pagos salientes.",
searchInfoNodeIdTitle = "ID del nodo (clave pública)",
searchInfoNodeIdDesc = "La clave pública del nodo de destino. " +
"Una cadena hexadecimal de 66 caracteres — puedes pegar solo los primeros.",
searchInfoFooter = "Puedes pegar un valor completo o buscar con solo unos pocos caracteres — " +
"las coincidencias parciales también funcionan.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Completado",
statusPending = "Pendiente",
statusFailed = "Fallido",
statusUnknown = "Desconocido",
detailOutgoingTitle = "Pago saliente",
detailIncomingTitle = "Pago entrante",
detailBack = "Atrás",
detailCouldNotLoad = "No se pudieron cargar los detalles completos",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Comisión: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Detalles",
detailRowDate = "Fecha",
detailRowMemo = "Nota",
detailRowMemoEmpty = "",
detailRowType = "Tipo",
detailRowComment = "Comentario",
detailSectionSuccessAction = "Acción de éxito",
detailSuccessMessage = "Mensaje",
detailSuccessUrl = "URL",
detailSuccessDescription = "Descripción",
detailSectionTechnical = "Técnico",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimagen",
detailRowDestination = "Destino",
detailCopyNodeId = "Copiar ID del nodo",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Enviado",
paymentRowReceivedCD = "Recibido",
paymentRowNoMemo = "Sin nota",
paymentRowPending = "Pendiente",
paymentRowFailed = "Fallido",
),
nwc = NwcStrings(
// ── Shared ────────────────────────────────────────────────────────────────
back = "Atrás",
next = "Siguiente",
done = "Listo",
cancel = "Cancelar",
schedule = "Programar",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Conexión",
connectionUnknownWallet = "App desconocida",
connectionSectionDetails = "Detalles",
connectionRowCreated = "Conectado",
connectionRowLastUsed = "Último uso",
connectionRowExpires = "Vence",
connectionRowPubkey = "Clave pública",
connectionNeverUsed = "Nunca",
lastUsedToday = "Usada hoy",
lastUsedYesterday = "Usada ayer",
lastUsedDate = "Usada %s",
connectionNoExpiry = "Sin vencimiento",
connectionExpired = { date -> "Vencido · $date" },
connectionSectionPermissions = "Permisos",
connectionSectionBudgets = "Presupuestos de gasto",
connectionBudgetTitle = { window -> "Presupuesto $window" },
connectionBudgetUsed = { sats -> "Usado: $sats sats" },
connectionBudgetTotal = { sats -> "de $sats sats" },
connectionRevoke = "Revocar conexión",
connectionStatusActive = "Activa",
connectionStatusExpired = "Vencida",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Pagar facturas",
permGetBalance = "Ver saldo",
permGetInfo = "Ver información del monedero",
permMakeInvoice = "Crear facturas",
permLookupInvoice = "Consultar facturas",
permListTransactions = "Ver historial de transacciones",
permSignMessage = "Firmar mensajes",
permPay = "Enviar pagos",
permInvoice = "Crear facturas",
permLookup = "Consultar estado de facturas",
permHistory = "Leer historial de transacciones",
permBalance = "Leer saldo del monedero",
permInfo = "Leer información de cuenta",
permLookupHistory = "Consultar estado e historial de transacciones",
permBalanceInfo = "Ver saldo e información de cuenta",
permSendPayments = "Enviar pagos",
permCreateInvoices = "Crear facturas",
permLookupInvoiceStatus = "Consultar estado de factura",
permReadTransactionHistory = "Leer historial de transacciones",
permReadWalletBalance = "Leer saldo del monedero",
permReadAccountInfo = "Leer información de cuenta",
revokeDialogTitle = "¿Revocar conexión?",
revokeDialogBody = "Esto revocará permanentemente esta clave Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
revokeDialogConfirm = "Revocar",
revokeDialogDismiss = "Cancelar",
refreshDaily = "Diario",
refreshWeekly = "Semanal",
refreshMonthly = "Mensual",
refreshYearly = "Anual",
refreshNever = "Nunca",
newConnection = "Nueva conexión",
labelHint = "Etiqueta (p.ej. Amethyst, Bitrefill)",
expires = "Caduca",
pickDate = "Elegir fecha",
pickTime = "Elegir hora",
never = "Nunca",
budgets = "Presupuestos",
addBudget = "Añadir presupuesto",
removeBudget = "Eliminar presupuesto",
permissions = "Permisos",
unnamed = "Sin nombre",
creating = "Creando…",
createConnection = "Crear conexión",
ok = "OK",
sats = "Sats",
resets = "Se reinicia",
deleteConnection = "Eliminar conexión",
unnamedConnection = "Conexión sin nombre",
neverUsed = "Nunca usada",
expired = "Caducada",
connectionCreated = "Conexión creada",
scanOrCopyHint = "Escanea o copia esta cadena de conexión en tu app. No se volverá a mostrar.",
nwcPairingQrCode = "Código QR de emparejamiento NWC",
nwcConnectionString = "Cadena de conexión NWC",
shareNwcConnectionString = "Compartir cadena de conexión NWC",
openInApp = "Abrir en app",
walletConnect = "Wallet Connect",
addConnection = "Añadir conexión",
noCompatibleAppFound = "No se encontró una app compatible",
removeConnection = "¿Eliminar conexión?",
removeConnectionDetail = "Esto revocará permanentemente esta clave de Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
remove = "Eliminar",
noConnectionsYet = "Aún no hay conexiones",
tapToAddHint = "Toca + para conectar una app vía Nostr Wallet Connect.",
somethingWentWrong = "Algo salió mal",
retry = "Reintentar",
failedToLoadConnections = "Error al cargar las conexiones",
failedToCreateConnection = "Error al crear la conexión",
failedToDeleteConnection = "Error al eliminar la conexión",
failedToLoadConnection = "Error al cargar la conexión",
),
lock = LockStrings(
appLockTitle = "Bloqueo de App",
appLockDescription = "Solicitar autenticación biométrica (huella, cara o PIN) cada vez que se abra Gudari Wallet.",
appLockToggleLabel = "Bloquear al abrir",
appLockToggleEnabled = "Activado (recomendado)",
appLockToggleDisabled = "Desactivado",
appName = "Gudari Wallet",
authenticateToContinue = "Autentícate para continuar",
unlockButton = "Desbloquear",
biometricPromptTitle = "Desbloquear Gudari Wallet",
biometricPromptSubtitle = "Autentícate para acceder a tu cartera",
),
)
@@ -54,61 +54,8 @@ val EuStrings = AppStrings(
details = "Xehetasunak",
enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" },
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Jaso",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Errorea",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning helbidearen QR kodea",
lightningAddressLabel = "Lightning helbidea",
shareLightningAddress = "Partekatu Lightning helbidea",
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
invoiceLabel = "Faktura",
shareInvoice = "Partekatu faktura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Zenbatekoa (sat)",
satsRange = { min, max -> "$min $max sat" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Oharra (aukerakoa)",
memoPlaceholder = "Zertarako da hau?",
creatingInvoice = "Faktura sortzen…",
createInvoice = "Sortu faktura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Zain…",
invoiceExpired = "Faktura iraungita",
newInvoice = "Faktura berria",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Atera hemendik:",
minimumSats = { min -> "Gutxienekoa $min sat da" },
maximumSats = { max -> "Gehienekoa $max sat da" },
withdrawButton = "Atera",
withdrawButtonAmount = { amount -> "Atera $amount sat" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Ordainketa jasota!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount sat" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Murriztu distira",
brightnessBoost = "Handitu distira",
qrShare = "Partekatu",
qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
today = "gaur",
yesterday = "atzo",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali",
@@ -196,118 +143,6 @@ val EuStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historia",
historyClose = "Itxi",
historySearchPayments = "Bilatu ordainketak",
historyFilterPayments = "Iragazi ordainketak",
historyRetry = "Saiatu berriro",
historyNoPayments = "Oraindik ez dago ordainketarik.",
historySearchPlaceholder = "Bilatu transakzioak",
historySearchHelp = "Bilaketa-laguntza",
historySearchPaste = "Itsatsi",
historySearchClear = "Garbitu bilaketa",
historySearchChip = { tail -> "bilaketa: …$tail" },
historyClearSearch = "Garbitu bilaketa",
filterDirectionOutgoing = "Irteerako",
filterDirectionIncoming = "Sarrerako",
filterClearDirection = "Garbitu norabide-iragazkia",
filterStatusCompleted = "Osatuta",
filterStatusPending = "Zain",
filterStatusFailed = "Huts eginda",
filterRemoveStatus = "Kendu egoera-iragazkia",
filterRemoveType = "Kendu mota-iragazkia",
filterAmountRange = { min, max -> "$min$max sat" },
filterAmountMin = { min -> "$min sat" },
filterAmountMax = { max -> "$max sat" },
filterClearAmount = "Garbitu zenbateko-iragazkia",
filterDateThisWeek = "Aste honetan",
filterDateThisMonth = "Hilabete honetan",
filterDateThisYear = "Urte honetan",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "$from-tik" },
filterDateUntil = { until -> "$until arte" },
filterClearDate = "Garbitu data-iragazkia",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Iragazi ordainketak",
filterSectionDirection = "Norabidea",
filterDirectionAll = "Guztiak",
filterSectionStatus = "Egoera",
filterSectionType = "Mota",
filterSectionAmount = "Zenbatekoa (sat)",
filterAmountMin_label = "Gutxienekoa",
filterAmountMax_label = "Gehienekoa",
filterAmountSuffix = "sat",
filterSectionDate = "Data",
filterDateFrom_label = "Nondik",
filterDateTo_label = "Nora arte",
filterDialogOk = "Ados",
filterDialogCancel = "Utzi",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "Zer bila dezaket?",
searchInfoSubtitle = "Idatzi hurrengoetako edozein zati ordainketa bat aurkitzeko:",
searchInfoMemoTitle = "Oharra",
searchInfoMemoDesc = "Ordainketa bati erantsitako mezua edo deskribapena. " +
"Adibidez, \"kafea\" edo \"martxoko alokairua\".",
searchInfoInvoiceTitle = "Faktura",
searchInfoInvoiceDesc = "Ordainketa bat bidaltzeko eskaneatu edo itsatsi zenuen ordainketa-eskaera. " +
"Normalean \"lnbc…\" hasierarekin dator. " +
"Lehen edo azken karaktere batzuk bakarrik itsats ditzakezu.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "Ordainketa hau Lightning Sarean identifikatzen duen kode bakarra. " +
"Erabilgarria norbaitek transakzio zehatz bat berresteko eskatzen dizunean.",
searchInfoPreimageTitle = "Aurre-irudia",
searchInfoPreimageDesc = "Ordainketa bat jaso dela frogatzen duen kode sekretua. " +
"Ordainketa arrakastaz osatu ondoren bakarrik dago erabilgarri.",
searchInfoNodeAliasTitle = "Nodo-izengoitia",
searchInfoNodeAliasDesc = "Ordainketa bidali zenion Lightning nodoaren izena. " +
"Adibidez, \"ACINQ\" edo \"Wallet of Satoshi\". " +
"Izengoitiak automatikoki ebazten dira irteerako ordainketetan.",
searchInfoNodeIdTitle = "Nodo IDa (gako publikoa)",
searchInfoNodeIdDesc = "Helmuga-nodoaren gako publikoa. " +
"66 karaktereko hex katea — lehen karaktere batzuk bakarrik itsats ditzakezu.",
searchInfoFooter = "Balio osoa itsatsi dezakezu edo karaktere gutxi batzuekin bilatu — " +
"zati-bat datozenak ere funtzionatzen dute.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Osatuta",
statusPending = "Zain",
statusFailed = "Huts eginda",
statusUnknown = "Ezezaguna",
detailOutgoingTitle = "Irteerako ordainketa",
detailIncomingTitle = "Sarrerako ordainketa",
detailBack = "Atzera",
detailCouldNotLoad = "Ezin izan dira xehetasun osoak kargatu",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Komisioa: $feeSat sat$fiatPart$ppmPart"
},
detailSectionDetails = "Xehetasunak",
detailRowDate = "Data",
detailRowMemo = "Oharra",
detailRowMemoEmpty = "",
detailRowType = "Mota",
detailRowComment = "Iruzkina",
detailSectionSuccessAction = "Arrakasta-ekintza",
detailSuccessMessage = "Mezua",
detailSuccessUrl = "URLa",
detailSuccessDescription = "Deskribapena",
detailSectionTechnical = "Teknikoa",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Aurre-irudia",
detailRowDestination = "Helmuga",
detailCopyNodeId = "Kopiatu nodo IDa",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Bidalita",
paymentRowReceivedCD = "Jasota",
paymentRowNoMemo = "Oharrik gabe",
paymentRowPending = "Zain",
paymentRowFailed = "Huts eginda",
onboarding = OnboardingStrings(
back = "Atzera",
next = "Hurrengoa",
@@ -325,6 +160,7 @@ val EuStrings = AppStrings(
hideAdminKey = "Ezkutatu",
showAdminKey = "Erakutsi",
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
invalidKeyFormat = "Gako formatu baliogabea",
connect = "Konektatu",
notificationsTitle = "Egon eguneratuta",
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
@@ -352,5 +188,299 @@ val EuStrings = AppStrings(
scheduleSync = "Programatu sinkronizazioa",
schedule = "Programatu",
cancel = "Utzi"
),
receive = ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
receive = "Jaso",
// ── ReceiveScreen — error ─────────────────────────────────────────────────
receiveError = "Errorea",
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
lightningAddressQrCode = "Lightning helbidearen QR kodea",
lightningAddressLabel = "Lightning helbidea",
shareLightningAddress = "Partekatu Lightning helbidea",
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
invoiceLabel = "Faktura",
shareInvoice = "Partekatu faktura",
// ── ReceiveScreen — amount step ───────────────────────────────────────────
amountInSats = "Zenbatekoa (sat)",
satsRange = { min, max -> "$min $max sat" },
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
memoOptional = "Oharra (aukerakoa)",
memoPlaceholder = "Zertarako da hau?",
creatingInvoice = "Faktura sortzen…",
createInvoice = "Sortu faktura",
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
waiting = "Zain…",
invoiceExpired = "Faktura iraungita",
newInvoice = "Faktura berria",
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
withdrawFrom = "Atera hemendik:",
minimumSats = { min -> "Gutxienekoa $min sat da" },
maximumSats = { max -> "Gehienekoa $max sat da" },
withdrawButton = "Atera",
withdrawButtonAmount = { amount -> "Atera $amount sat" },
// ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Ordainketa jasota!",
// ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount sat" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Murriztu distira",
brightnessBoost = "Handitu distira",
qrShare = "Partekatu",
qrCopy = "Kopiatu",
qrNfcStop = "Gelditu NFC partekatzea",
qrNfcStart = "Partekatu NFC bidez",
openInApp = "Ireki aplikazioan",
noAppFound = "Ez da aurkitu aplikazio bateragarririk",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
),
history = HistoryStrings(
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historia",
historyClose = "Itxi",
historySearchPayments = "Bilatu ordainketak",
historyFilterPayments = "Iragazi ordainketak",
historyRetry = "Saiatu berriro",
historyNoPayments = "Oraindik ez dago ordainketarik.",
historySearchPlaceholder = "Bilatu transakzioak",
historySearchHelp = "Bilaketa-laguntza",
historySearchPaste = "Itsatsi",
historySearchClear = "Garbitu bilaketa",
historySearchChip = { tail -> "bilaketa: …$tail" },
historyClearSearch = "Garbitu bilaketa",
filterDirectionOutgoing = "Irteerako",
filterDirectionIncoming = "Sarrerako",
filterClearDirection = "Garbitu norabide-iragazkia",
filterStatusCompleted = "Osatuta",
filterStatusPending = "Zain",
filterStatusFailed = "Huts eginda",
filterRemoveStatus = "Kendu egoera-iragazkia",
filterRemoveType = "Kendu mota-iragazkia",
filterAmountRange = { min, max -> "$min$max sat" },
filterAmountMin = { min -> "$min sat" },
filterAmountMax = { max -> "$max sat" },
filterClearAmount = "Garbitu zenbateko-iragazkia",
filterDateThisWeek = "Aste honetan",
filterDateThisMonth = "Hilabete honetan",
filterDateThisYear = "Urte honetan",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "$from-tik" },
filterDateUntil = { until -> "$until arte" },
filterClearDate = "Garbitu data-iragazkia",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Iragazi ordainketak",
filterSectionDirection = "Norabidea",
filterDirectionAll = "Guztiak",
filterSectionStatus = "Egoera",
filterSectionType = "Mota",
filterSectionAmount = "Zenbatekoa (sat)",
filterAmountMin_label = "Gutxienekoa",
filterAmountMax_label = "Gehienekoa",
filterAmountSuffix = "sat",
filterSectionDate = "Data",
filterDateFrom_label = "Nondik",
filterDateTo_label = "Nora arte",
filterDialogOk = "Ados",
filterDialogCancel = "Utzi",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "Zer bila dezaket?",
searchInfoSubtitle = "Idatzi hurrengoetako edozein zati ordainketa bat aurkitzeko:",
searchInfoMemoTitle = "Oharra",
searchInfoMemoDesc = "Ordainketa bati erantsitako mezua edo deskribapena. " +
"Adibidez, \"kafea\" edo \"martxoko alokairua\".",
searchInfoInvoiceTitle = "Faktura",
searchInfoInvoiceDesc = "Ordainketa bat bidaltzeko eskaneatu edo itsatsi zenuen ordainketa-eskaera. " +
"Normalean \"lnbc…\" hasierarekin dator. " +
"Lehen edo azken karaktere batzuk bakarrik itsats ditzakezu.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "Ordainketa hau Lightning Sarean identifikatzen duen kode bakarra. " +
"Erabilgarria norbaitek transakzio zehatz bat berresteko eskatzen dizunean.",
searchInfoPreimageTitle = "Aurre-irudia",
searchInfoPreimageDesc = "Ordainketa bat jaso dela frogatzen duen kode sekretua. " +
"Ordainketa arrakastaz osatu ondoren bakarrik dago erabilgarri.",
searchInfoNodeAliasTitle = "Nodo-izengoitia",
searchInfoNodeAliasDesc = "Ordainketa bidali zenion Lightning nodoaren izena. " +
"Adibidez, \"ACINQ\" edo \"Wallet of Satoshi\". " +
"Izengoitiak automatikoki ebazten dira irteerako ordainketetan.",
searchInfoNodeIdTitle = "Nodo IDa (gako publikoa)",
searchInfoNodeIdDesc = "Helmuga-nodoaren gako publikoa. " +
"66 karaktereko hex katea — lehen karaktere batzuk bakarrik itsats ditzakezu.",
searchInfoFooter = "Balio osoa itsatsi dezakezu edo karaktere gutxi batzuekin bilatu — " +
"zati-bat datozenak ere funtzionatzen dute.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
statusSuccess = "Osatuta",
statusPending = "Zain",
statusFailed = "Huts eginda",
statusUnknown = "Ezezaguna",
detailOutgoingTitle = "Irteerako ordainketa",
detailIncomingTitle = "Sarrerako ordainketa",
detailBack = "Atzera",
detailCouldNotLoad = "Ezin izan dira xehetasun osoak kargatu",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Komisioa: $feeSat sat$fiatPart$ppmPart"
},
detailSectionDetails = "Xehetasunak",
detailRowDate = "Data",
detailRowMemo = "Oharra",
detailRowMemoEmpty = "",
detailRowType = "Mota",
detailRowComment = "Iruzkina",
detailSectionSuccessAction = "Arrakasta-ekintza",
detailSuccessMessage = "Mezua",
detailSuccessUrl = "URLa",
detailSuccessDescription = "Deskribapena",
detailSectionTechnical = "Teknikoa",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Aurre-irudia",
detailRowDestination = "Helmuga",
detailCopyNodeId = "Kopiatu nodo IDa",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Bidalita",
paymentRowReceivedCD = "Jasota",
paymentRowNoMemo = "Oharrik gabe",
paymentRowPending = "Zain",
paymentRowFailed = "Huts eginda",
),
nwc = NwcStrings(
// ── Shared ────────────────────────────────────────────────────────────────
back = "Atzera",
next = "Hurrengoa",
done = "Eginda",
cancel = "Utzi",
schedule = "Programatu",
// ── Connection detail ─────────────────────────────────────────────────────
connectionDetailTitle = "Konexioa",
connectionUnknownWallet = "App ezezaguna",
connectionSectionDetails = "Xehetasunak",
connectionRowCreated = "Konektatuta",
connectionRowLastUsed = "Azken erabilera",
lastUsedToday = "Duela gutxi erabilia", // "used recently" (idiomatic for today)
lastUsedYesterday = "Atzo erabilia", // "used yesterday"
lastUsedDate = "Duela %s erabilia", // "used [date] ago"
connectionRowExpires = "Iraungitze-data",
connectionRowPubkey = "Gako publikoa",
connectionNeverUsed = "Inoiz ez",
connectionNoExpiry = "Iraungitze-datarik ez",
connectionExpired = { date -> "Iraungita · $date" },
connectionSectionPermissions = "Baimenak",
connectionSectionBudgets = "Gastu-aurrekontuak",
connectionBudgetTitle = { window -> "$window aurrekontua" },
connectionBudgetUsed = { sats -> "Erabilita: $sats sat" },
connectionBudgetTotal = { sats -> "$sats satetatik" },
connectionRevoke = "Konexioa baliogabetu",
connectionStatusActive = "Aktibo",
connectionStatusExpired = "Iraungita",
// ── NWC permissions ───────────────────────────────────────────────────────
permPayInvoice = "Fakturak ordaindu",
permGetBalance = "Saldoa ikusi",
permGetInfo = "Zorroko informazioa ikusi",
permMakeInvoice = "Fakturak sortu",
permLookupInvoice = "Fakturak kontsultatu",
permListTransactions = "Transakzio-historia ikusi",
permSignMessage = "Mezuak sinatu",
permPay = "Ordainketak bidali",
permInvoice = "Fakturak sortu",
permLookup = "Kontsultatu faktura-egoera",
permHistory = "Irakurri transakzio-historiala",
permBalance = "Irakurri zorroaren saldoa",
permInfo = "Irakurri kontu-informazioa",
permLookupHistory = "Egoera eta transakzio-historia kontsultatu",
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
permSendPayments = "Ordainketak bidali",
permCreateInvoices = "Fakturak sortu",
permLookupInvoiceStatus = "Fakturaren egoera kontsultatu",
permReadTransactionHistory = "Transakzio-historia irakurri",
permReadWalletBalance = "Zorroko saldoa irakurri",
permReadAccountInfo = "Kontuaren informazioa irakurri",
revokeDialogTitle = "Konexioa baliogabetu?",
revokeDialogBody = "Honek Nostr Wallet Connect gako hau behin betiko baliogabetuko du. Erabiltzen duen edozein aplikaziok sarbidea galduko du.",
revokeDialogConfirm = "Baliogabetu",
revokeDialogDismiss = "Utzi",
refreshDaily = "Egunero",
refreshWeekly = "Astero",
refreshMonthly = "Hilabetero",
refreshYearly = "Urtero",
refreshNever = "Inoiz ez",
newConnection = "Konexio berria",
labelHint = "Etiketa (adb. Amethyst, Bitrefill)",
expires = "Iraungitzen da",
pickDate = "Aukeratu data",
pickTime = "Aukeratu ordua",
never = "Inoiz ez",
budgets = "Aurrekontuak",
addBudget = "Gehitu aurrekontua",
removeBudget = "Kendu aurrekontua",
permissions = "Baimenak",
unnamed = "Izengabea",
creating = "Sortzen…",
createConnection = "Sortu konexioa",
ok = "Ados",
sats = "Sats",
resets = "Berrezarri",
deleteConnection = "Ezabatu konexioa",
unnamedConnection = "Izengabeko konexioa",
neverUsed = "Inoiz erabili gabea",
expired = "Iraungitua",
connectionCreated = "Konexioa sortuta",
scanOrCopyHint = "Eskaneatu edo kopiatu konexio-kate hau zure app-ean. Ez da berriro erakutsiko.",
nwcPairingQrCode = "NWC parekatzeko QR kodea",
nwcConnectionString = "NWC konexio-katea",
shareNwcConnectionString = "Partekatu NWC konexio-katea",
openInApp = "Ireki app-ean",
walletConnect = "Wallet Connect",
addConnection = "Gehitu konexioa",
noCompatibleAppFound = "Ez da app bateragarririk aurkitu",
removeConnection = "Kendu konexioa?",
removeConnectionDetail = "Honek Nostr Wallet Connect gako hau behin betiko indargabetuko du. Erabiltzen duen edozein app-ek sarbidea galduko du.",
remove = "Kendu",
noConnectionsYet = "Oraindik konexiorik ez",
tapToAddHint = "Sakatu + app bat Nostr Wallet Connect bidez konektatzeko.",
somethingWentWrong = "Zerbait gaizki atera da",
retry = "Saiatu berriro",
failedToLoadConnections = "Huts egin du konexioak kargatzean",
failedToCreateConnection = "Huts egin du konexioa sortzean",
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
failedToLoadConnection = "Huts egin du konexioa kargatzean",
),
lock = LockStrings(
appLockTitle = "App Blokeoa",
appLockDescription = "Autentifikazio biometrikoa (hatz-marka, aurpegia edo PINa) eskatu Gudari Wallet irekitzen den bakoitzean.",
appLockToggleLabel = "Aplikazioa irekitzean blokeatu",
appLockToggleEnabled = "Gaituta (gomendatua)",
appLockToggleDisabled = "Desgaituta",
appName = "Gudari Wallet",
authenticateToContinue = "Autentifikatu jarraitzeko",
unlockButton = "Desblokeatu",
biometricPromptTitle = "Desblokeatu Gudari Wallet",
biometricPromptSubtitle = "Autentifikatu zure zorrora sartzeko",
)
)
@@ -63,61 +63,8 @@ data class AppStrings(
val details : String,
val enterValidAmount : String,
val satsUnit : (sats: Long) -> String,
// ── ReceiveScreen — general ───────────────────────────────────────────────
val receive : String,
// ── ReceiveScreen — error ─────────────────────────────────────────────────
val receiveError : String,
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
val lightningAddressQrCode : String,
val lightningAddressLabel : String,
val shareLightningAddress : String,
val lightningInvoiceQrCode : String,
val invoiceLabel : String,
val shareInvoice : String,
// ── ReceiveScreen — amount step ───────────────────────────────────────────
val amountInSats : String, // text field label
val satsRange : (min: Long, max: Long) -> String,
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
val memoOptional : String,
val memoPlaceholder : String,
val creatingInvoice : String,
val createInvoice : String,
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
val waiting : String,
val invoiceExpired : String,
val newInvoice : String,
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
val withdrawFrom : String,
val minimumSats : (min: Long) -> String,
val maximumSats : (max: Long) -> String,
val withdrawButton : String, // no fixed amount
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
// ── ReceiveScreen — success ───────────────────────────────────────────────
val paymentReceived : String,
// ── AmountDisplay ─────────────────────────────────────────────────────────
val amountDisplaySats : (amount: Long) -> String,
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
val brightnessReduce : String,
val brightnessBoost : String,
val qrShare : String,
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
val receiveVmWithdrawRejected : String,
val receiveVmWithdrawFailed : String,
val today : String,
val yesterday : String,
// ── SendScreen — general ──────────────────────────────────────────────────
val send : String,
@@ -204,6 +151,120 @@ data class AppStrings(
val sendVmUnsupportedType : (tag: String) -> String,
val sendVmPaymentFailedRescan : String,
val onboarding: OnboardingStrings,
val receive: ReceiveStrings,
val history: HistoryStrings,
val nwc: NwcStrings,
val lock: LockStrings
)
data class OnboardingStrings(
val title: String,
val subtitle: String,
val serverUrl: String,
val serverUrlHint: String,
val invoiceKey: String,
val invoiceKeySubtitle: String,
val pasteInvoiceKey: String,
val adminKey: String,
val adminKeySubtitle: String,
val pasteAdminKey: String,
val showAdminKey: String,
val hideAdminKey: String,
val connect: String,
val fieldsRequired: String,
val invalidKeyFormat: String,
val back: String,
val next: String,
val done: String,
val notificationsTitle: String,
val notificationsSubtitle: String,
val notificationsAlreadyGranted: String,
val allowNotifications: String,
val notificationsNote: String,
val notificationsAutoGranted: String,
val batteryTitle: String,
val batterySubtitle: String,
val openSettings: String,
val batteryNote: String,
val batteryAlreadyUnrestricted: String,
val paymentHistory: String,
val paymentHistorySubtitle: String,
val syncNow: String,
val skipForNow: String,
val syncScheduled: String,
val continueToApp: String,
val connecting: String,
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
val paymentsSynced: (total: Int) -> String,
val syncComplete: String,
val syncFailed: String,
val scheduleForLater: String,
val scheduleSync: String,
val schedule: String,
val cancel: String
)
data class ReceiveStrings(
// ── ReceiveScreen — general ───────────────────────────────────────────────
val receive : String,
// ── ReceiveScreen — error ─────────────────────────────────────────────────
val receiveError : String,
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
val lightningAddressQrCode : String,
val lightningAddressLabel : String,
val shareLightningAddress : String,
val lightningInvoiceQrCode : String,
val invoiceLabel : String,
val shareInvoice : String,
// ── ReceiveScreen — amount step ───────────────────────────────────────────
val amountInSats : String, // text field label
val satsRange : (min: Long, max: Long) -> String,
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
val memoOptional : String,
val memoPlaceholder : String,
val creatingInvoice : String,
val createInvoice : String,
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
val waiting : String,
val invoiceExpired : String,
val newInvoice : String,
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
val withdrawFrom : String,
val minimumSats : (min: Long) -> String,
val maximumSats : (max: Long) -> String,
val withdrawButton : String, // no fixed amount
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
// ── ReceiveScreen — success ───────────────────────────────────────────────
val paymentReceived : String,
// ── AmountDisplay ─────────────────────────────────────────────────────────
val amountDisplaySats : (amount: Long) -> String,
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
val brightnessReduce : String,
val brightnessBoost : String,
val qrShare : String,
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
val openInApp : String,
val noAppFound : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
val receiveVmWithdrawRejected : String,
val receiveVmWithdrawFailed : String,
)
data class HistoryStrings(
// ── HistoryScreen ─────────────────────────────────────────────────────────
val historyTitle : String,
val historyClose : String,
@@ -303,52 +364,133 @@ data class AppStrings(
val paymentRowNoMemo : String,
val paymentRowPending : String,
val paymentRowFailed : String,
val onboarding: OnboardingStrings
)
data class OnboardingStrings(
val title: String,
val subtitle: String,
val serverUrl: String,
val serverUrlHint: String,
val invoiceKey: String,
val invoiceKeySubtitle: String,
val pasteInvoiceKey: String,
val adminKey: String,
val adminKeySubtitle: String,
val pasteAdminKey: String,
val showAdminKey: String,
val hideAdminKey: String,
val connect: String,
val fieldsRequired: String,
val back: String,
val next: String,
val done: String,
val notificationsTitle: String,
val notificationsSubtitle: String,
val notificationsAlreadyGranted: String,
val allowNotifications: String,
val notificationsNote: String,
val notificationsAutoGranted: String,
val batteryTitle: String,
val batterySubtitle: String,
val openSettings: String,
val batteryNote: String,
val batteryAlreadyUnrestricted: String,
val paymentHistory: String,
val paymentHistorySubtitle: String,
val syncNow: String,
val skipForNow: String,
val syncScheduled: String,
val continueToApp: String,
val connecting: String,
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
val paymentsSynced: (total: Int) -> String,
val syncComplete: String,
val syncFailed: String,
val scheduleForLater: String,
val scheduleSync: String,
val schedule: String,
val cancel: String
data class NwcStrings(
// ── Shared / navigation ───────────────────────────────────────────────────
val back : String,
val next : String,
val done : String,
val cancel : String,
val schedule : String,
// ── Connection detail ─────────────────────────────────────────────────────
val connectionDetailTitle : String,
val connectionUnknownWallet : String,
val connectionSectionDetails : String,
val connectionRowCreated : String,
val connectionRowLastUsed : String,
val connectionRowExpires : String,
val connectionRowPubkey : String,
val connectionNeverUsed : String,
val lastUsedToday : String, // e.g. "Last used today"
val lastUsedYesterday : String, // e.g. "Last used yesterday"
val lastUsedDate : String, // e.g. "Last used %s" — %s is a formatted date
val connectionNoExpiry : String,
/** "Expired · {date}" */
val connectionExpired : (date: String) -> String,
val connectionSectionPermissions : String,
val connectionSectionBudgets : String,
/** "{window} budget" e.g. "Daily budget" */
val connectionBudgetTitle : (window: String) -> String,
/** "Used: {sats} sats" */
val connectionBudgetUsed : (sats: Long) -> String,
/** "of {sats} sats" */
val connectionBudgetTotal : (sats: Long) -> String,
val connectionRevoke : String,
val connectionStatusActive : String,
val connectionStatusExpired : String,
// ── NWC permission labels ─────────────────────────────────────────────────
val permPayInvoice : String,
val permGetBalance : String,
val permGetInfo : String,
val permMakeInvoice : String,
val permLookupInvoice : String,
val permListTransactions : String,
val permSignMessage : String,
val permPay : String, // "Send payments"
val permInvoice : String, // "Create invoices"
val permLookup : String,
val permHistory : String,
val permBalance : String,
val permInfo : String,
val permLookupHistory : String, // "Lookup status & transaction history"
val permBalanceInfo : String, // "Read balance & account info"
val permSendPayments : String,
val permCreateInvoices : String,
val permLookupInvoiceStatus: String,
val permReadTransactionHistory: String,
val permReadWalletBalance: String,
val permReadAccountInfo: String,
val revokeDialogTitle : String,
val revokeDialogBody : String,
val revokeDialogConfirm : String,
val revokeDialogDismiss : String,
val refreshDaily : String,
val refreshWeekly : String,
val refreshMonthly : String,
val refreshYearly : String,
val refreshNever : String,
// New Connection
val newConnection : String,
val labelHint : String,
val expires : String,
val pickDate : String,
val pickTime : String,
val never : String,
val budgets : String,
val addBudget : String,
val removeBudget : String,
val permissions : String,
val unnamed : String,
val creating : String,
val createConnection : String,
val ok : String,
val sats : String,
val resets : String,
val deleteConnection : String,
val unnamedConnection : String,
val neverUsed : String,
val expired : String,
val connectionCreated : String,
val scanOrCopyHint : String,
val nwcPairingQrCode : String,
val nwcConnectionString : String,
val shareNwcConnectionString : String,
val openInApp : String,
val walletConnect : String,
val addConnection : String,
val noCompatibleAppFound : String,
val removeConnection : String,
val removeConnectionDetail: String,
val remove : String,
val noConnectionsYet : String,
val tapToAddHint : String,
val somethingWentWrong : String,
val retry : String,
val failedToLoadConnections : String,
val failedToCreateConnection : String,
val failedToDeleteConnection : String,
val failedToLoadConnection : String,
)
data class LockStrings(
val appLockTitle: String,
val appLockDescription: String,
val appLockToggleLabel: String,
val appLockToggleEnabled: String,
val appLockToggleDisabled: String,
val appName: String,
val authenticateToContinue: String,
val unlockButton: String,
val biometricPromptTitle: String,
val biometricPromptSubtitle: String,
)
@@ -1,8 +1,8 @@
package com.bitcointxoko.gudariwallet.i18n
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
"success" -> statusSuccess
"pending" -> statusPending
"failed" -> statusFailed
else -> statusUnknown
"success" -> history.statusSuccess
"pending" -> history.statusPending
"failed" -> history.statusFailed
else -> history.statusUnknown
}
@@ -1,20 +1,15 @@
package com.bitcointxoko.gudariwallet.security
import android.content.Context
import timber.log.Timber
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.migrations.SharedPreferencesMigration
import androidx.datastore.migrations.SharedPreferencesView
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.File
import androidx.core.content.edit
/**
* Singleton DataStore for encrypted credentials.
@@ -27,8 +22,6 @@ import androidx.core.content.edit
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
*/
object CredentialsDataStore {
private const val TAG = "CredentialsDataStore"
@Volatile
private var instance: DataStore<Credentials>? = null
@@ -25,8 +25,8 @@ class CredentialsSerializer(
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
result
} catch (e: Exception) {
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e)
throw CorruptionException("Cannot read/decrypt credentials DataStore", e)
Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
throw CorruptionException("Cannot read/decrypt credentials DataStore")
}
}
@@ -37,12 +37,10 @@ class CredentialsSerializer(
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
encryptingStream.close()
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " +
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
} catch (e: Exception) {
Timber.e("Encryption/write failed", e)
Timber.e("Encryption/write failed")
throw e
}
}
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
Timber.i("StreamingAead primitive acquired successfully")
}
} catch (e: Exception) {
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable", e)
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable")
throw e
}
}
@@ -41,7 +41,7 @@ class NodeAliasService(
repo.put(pubkey, alias)
// 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias)
_aliases.value += (pubkey to alias)
Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"")
return alias
@@ -7,8 +7,8 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
@@ -30,6 +30,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "WalletNotifService"
@@ -98,7 +99,7 @@ class WalletNotificationService : Service() {
private var reconnectAttempts = 0
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
private val gson = GsonProvider.gson
private val json = JsonProvider.json
// ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -157,7 +158,7 @@ class WalletNotificationService : Service() {
val request = Request.Builder().url(wsUrl).build()
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() {
webSocket = LNbitsHttpClient.instance.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("WebSocket connected")
@@ -193,7 +194,7 @@ class WalletNotificationService : Service() {
private fun handleMessage(text: String) {
val msg = runCatching {
gson.fromJson(text, WsPaymentMessage::class.java)
json.decodeFromString(WsPaymentMessage.serializer(), text)
}.getOrNull() ?: return
val payment = msg.payment ?: return
@@ -271,7 +272,7 @@ class WalletNotificationService : Service() {
reconnectJob?.cancel()
reconnectJob = serviceScope.launch {
delay(backoff)
delay(backoff.milliseconds)
openWebSocket()
}
}
@@ -25,6 +25,8 @@ import androidx.compose.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.People
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -38,6 +40,8 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit,
onNwcClick : () -> Unit,
onContactsClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) {
val strings = LocalAppStrings.current
@@ -50,235 +54,258 @@ fun HomeTab(
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
}
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "••••"
else formatFiat(s.fiatAmount, s.fiatCurrency),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(4.dp))
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = strings.changeCurrency,
indication = null,
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 4.dp, vertical = 4.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
}
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "••••"
else formatFiat(s.fiatAmount, s.fiatCurrency),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(4.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = strings.changeCurrency,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
Text(
text = currency,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.75f
)
)
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
} else {
TextButton(
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = currency,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
text = strings.showFiat,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
}
}
} else {
TextButton(
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
}
Text(
text = strings.showFiat,
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
// ── NEW: Language switcher ─────────────────────────────────
// Spacer(Modifier.height(8.dp))
// LanguageSwitcherButton(
// currentTag = lyricist.languageTag,
// supportedTags = supportedTags,
// onSelect = { tag -> lyricist.languageTag = tag }
// )
// ──────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
strings.couldNotRefresh
else
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
strings.couldNotRefresh
else
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
IconButton(onClick = onContactsClick) {
Icon(
imageVector = Icons.Filled.People,
contentDescription = "Contacts",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
IconButton(onClick = onNwcClick) {
Icon(
imageVector = Icons.Filled.Cable,
contentDescription = "Wallet Connect",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
@@ -23,13 +23,13 @@ sealed class ReceiveState {
val amountSats : Long,
val memo : String,
val expiresAt : Long
) : ReceiveState()
data class PaymentReceived(
) : ReceiveState()
data class PaymentReceived(
val amountSats: Long,
val memo : String?,
val checkingId : String
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
val paymentHash : String
) : ReceiveState()
data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady(
val k1 : String,
val callback : String,
@@ -85,14 +85,18 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String,
override val createdAt : Instant,
override val expiresAt : Instant
override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice
data object Paying : SendState()
data class PaymentSent(
val amountSats : Long,
val feeSats : Long? = null,
val paymentHash: String,
val pendingRecord: PaymentRecord? = null
val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState()
data class Error(val message: String) : SendState()
}
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -57,16 +59,26 @@ import cafe.adriel.lyricist.Lyricist
import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailScreen
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── Tab destinations ──────────────────────────────────────────────────────────
// Note: label and icon are now resolved at the call site via LocalAppStrings,
@@ -80,8 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner"
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_CONNECTION_DETAIL = "connection_detail/{pubkey}"
private const val ROUTE_CONTACTS = "contacts"
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
fun WalletScreen(
vm : WalletViewModel,
@@ -115,11 +133,25 @@ fun WalletScreen(
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
feedRepository = PaymentFeedRepository(
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
nodeAliasRepository = vm.aliasRepo,
fiatVm = vm.fiatVm
),
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
)
val contactsVm: ContactsViewModel = viewModel(
factory = ContactsViewModel.Factory(vm.contactRepo)
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
@@ -127,10 +159,18 @@ fun WalletScreen(
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL
&& currentRoute != ROUTE_CONTACTS
&& currentRoute != ROUTE_CONTACT_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL
|| currentRoute == ROUTE_CONTACTS
|| currentRoute == ROUTE_CONTACT_DETAIL
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
@@ -192,7 +232,7 @@ fun WalletScreen(
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
delay(8_000.milliseconds)
vm.dismissClipboardOffer()
}
}
@@ -202,7 +242,7 @@ fun WalletScreen(
LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) {
delay(8_000)
delay(8_000.milliseconds)
nfcVm.dismissNfcOffer()
}
}
@@ -322,6 +362,14 @@ fun WalletScreen(
launchSingleTop = true
}
},
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
onContactsClick = {
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
},
lyricist = lyricist
)
}
@@ -329,18 +377,23 @@ fun WalletScreen(
ReceiveScreen(
vm = vm,
nfcVm = nfcVm,
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$paymentHash")
}
)
}
composable(TabItem.Send.route) {
SendScreen(
vm = vm,
vm = vm,
nfcVm = nfcVm,
onViewDetails = { checkingId, record ->
onViewDetails = { paymentHash, record ->
historyVm.primePayment(record)
navController.navigate("payment_detail/$checkingId") {
navController.navigate("payment_detail/$paymentHash") {
launchSingleTop = true
}
},
onViewContact = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
@@ -379,34 +432,30 @@ fun WalletScreen(
onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onPaymentClick = { payment ->
navController.navigate("payment_detail/${payment.checkingId}")
onPaymentClick = { item ->
navController.navigate("payment_detail/${item.paymentHash}")
}
)
}
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry ->
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
val payment = remember(historyState, fallbackState, checkingId) {
(historyState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
?: (fallbackState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
val item = remember(feedItems, paymentHash) {
historyVm.findPayment(paymentHash)
}
if (payment != null) {
if (item != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
item = item,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
LaunchedEffect(paymentHash) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -415,6 +464,114 @@ fun WalletScreen(
}
}
}
composable(
ROUTE_NWC,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
NwcScreen(
viewModel = nwcVm,
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onConnectionClick = { pubkey ->
navController.navigate("connection_detail/$pubkey") {
launchSingleTop = true
}
}
)
}
composable(
route = ROUTE_CONNECTION_DETAIL,
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) {
is ConnectionDetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ConnectionDetailState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
is ConnectionDetailState.Success -> {
ConnectionDetailScreen(
connection = state.connection,
budgets = state.budgets,
onBack = { navController.popBackStack() },
onRevoke = {
nwcVm.deleteConnection(pubkey, strings)
navController.popBackStack()
}
)
}
}
}
composable(
ROUTE_CONTACTS,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
ContactsScreen(
viewModel = contactsVm,
onContactClick = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
}
)
}
composable(
route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel(
key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
)
ContactDetailScreen(
viewModel = detailVm,
onBack = { navController.popBackStack() },
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 ────
@@ -11,7 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -19,7 +23,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
private const val TAG = "WalletViewModel"
@@ -28,6 +34,8 @@ class WalletViewModel(
val repo : WalletRepository,
val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository,
val contactRepo : ContactRepository,
private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel,
@@ -97,7 +105,8 @@ class WalletViewModel(
amountMsat: Long,
comment : String?,
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) =
sendVm.payLnurlInvoice(state, strings)
@@ -109,6 +118,16 @@ class WalletViewModel(
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) {
receiveVm.handleWithdrawResponse(raw, lnurl)
}
@@ -124,6 +143,32 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
sendVm.sendState
.map { state ->
val lnurl = (state as? SendState.LnurlReady)?.lnurl
?: return@map null
contactRepo.findByLnAddress(lnurl)
?: contactRepo.findByLnurl(lnurl)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
}
}
init {
observePaymentEvents()
fetchLightningAddress()
@@ -11,6 +11,8 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService
@@ -24,17 +26,19 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app)
val api = LNbitsClient.create(secrets)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
val lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app)
val contactRepo = ContactRepository(app)
val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app)
@@ -48,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner,
payer = lnurlPayer,
poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
)
val clipboardVm = ClipboardViewModel(
@@ -62,6 +68,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
nwcCache = nwcCache,
contactRepo = contactRepo,
balancePrefs = balancePrefs,
balanceVm = balanceVm,
fiatVm = fiatVm,
@@ -75,11 +75,10 @@ class BalanceViewModel(
runCatching { repo.getBalance() }
.onSuccess { balance ->
val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats
when {
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
prev == sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
}
_balanceState.value = BalanceState.Success(
sats = sats,
@@ -0,0 +1,41 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
@@ -0,0 +1,41 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.*
/**
* Manages screen brightness for QR-display screens.
* Returns a [BrightnessController] with [isOn] state and a [toggle] callback.
* Restores system brightness unconditionally when it leaves composition.
*/
@Composable
fun rememberBrightnessController(): BrightnessController {
val window = (LocalActivity.current)?.window
var isOn by remember { mutableStateOf(false) }
// Sync side effect: no coroutine needed
SideEffect {
window?.attributes = window.attributes?.apply {
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
DisposableEffect(Unit) {
onDispose {
window?.attributes = window.attributes?.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
}
// No remember needed — BrightnessController is a tiny data class,
// and this composable already recomposes only when isOn changes.
return BrightnessController(isOn = isOn, toggle = { isOn = !isOn })
}
data class BrightnessController(
val isOn : Boolean,
val toggle : () -> Unit
)
@@ -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,48 @@
package com.bitcointxoko.gudariwallet.ui.common
import android.content.ClipData
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -0,0 +1,93 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null, // ← from components
monospace : Boolean = false, // ← from history
copyable : Boolean = false, // ← from history
onCopy : (() -> Unit)? = null, // ← from history
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val baseStyle = MaterialTheme.typography.bodySmall
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = baseStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
style = baseStyle.copy(
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
textDecoration = textDecoration
),
color = valueColor,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -0,0 +1,32 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@@ -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 }
)
}
}
@@ -1,4 +1,4 @@
package com.bitcointxoko.gudariwallet.ui.receive
package com.bitcointxoko.gudariwallet.ui.common
import android.content.ClipData
import android.graphics.Bitmap
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share
@@ -55,6 +56,22 @@ import kotlinx.coroutines.launch
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
*/
/**
* QR image followed by Share / Copy / Brightness / NFC / Open-in-app icon-button row.
*
* @param content What to encode into the QR code.
* @param contentDescription Accessibility label for the QR image.
* @param clipLabel Label used when writing to the clipboard.
* @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped.
* @param isNfcEmulating Current NFC emulation state (null = hide NFC button).
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive.
* @param onOpenUri When non-null, shows an "Open in app" icon button that
* fires this callback. Pass null (default) to hide it.
*/
@Composable
internal fun QrDisplayCard(
modifier : Modifier = Modifier,
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
textToCopy : String,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
isNfcEmulating : Boolean? = null, // null → no NFC button shown
isNfcEmulating : Boolean? = null,
onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true,
onOpenUri : (() -> Unit)? = null, // ← NEW
qrModifier : Modifier = Modifier.size(260.dp)
) {
val strings = LocalAppStrings.current
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp))
)
// ── Action row: Share | Copy | Brightness | NFC ───────────────────────
// ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = strings.qrShare, // ← "Share"
contentDescription = strings.receive.qrShare,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = strings.qrCopy, // ← "Copy"
contentDescription = strings.receive.qrCopy,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
FilledTonalIconButton(onClick = onToggleBrightness) {
Icon(
imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness"
else strings.brightnessBoost, // ← "Boost brightness"
contentDescription = if (brightnessOn) strings.receive.brightnessReduce
else strings.receive.brightnessBoost,
tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc
else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing"
else strings.qrNfcStart, // ← "Share via NFC"
contentDescription = if (isNfcEmulating) strings.receive.qrNfcStop
else strings.receive.qrNfcStart,
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Open in app — only shown when caller passes onOpenUri
if (onOpenUri != null) {
IconButton(onClick = onOpenUri) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = strings.receive.openInApp,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// ── QR generation ─────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512
@@ -0,0 +1,53 @@
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.history.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.history.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.history.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.history.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
@@ -1,4 +1,4 @@
package com.bitcointxoko.gudariwallet.ui.components
package com.bitcointxoko.gudariwallet.ui.common
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
@@ -1,106 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.components
import android.content.ClipData
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
/**
* A two-column label/value row used throughout the send flow.
*/
@Composable
fun DetailRow(
label : String,
value : String,
subtitle : String? = null,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Row(
modifier = Modifier
.weight(0.65f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f, fill = false)) {
Text(
text = value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = valueColor,
style = LocalTextStyle.current.copy(textDecoration = textDecoration),
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
else Modifier
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailingContent?.invoke()
}
}
}
/**
* A small icon button that copies [value] to the clipboard under [label].
*/
@Composable
fun CopyIconButton(
label : String,
value : String,
size : Dp = 32.dp,
iconSize : Dp = 16.dp,
tint : Color = Color.Unspecified
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
}
},
modifier = Modifier.size(size)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(iconSize),
tint = if (tint == Color.Unspecified)
MaterialTheme.colorScheme.onSurfaceVariant
else tint
)
}
}
@@ -0,0 +1,794 @@
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.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailScreen(
viewModel: ContactDetailViewModel,
onBack: () -> Unit,
onPayAddress: (address: String) -> Unit = {}
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = {
val title = when (val s = uiState) {
is ContactDetailUiState.Success ->
s.data.contact.localAlias
?: s.data.contact.displayName
?: s.data.contact.name
?: "Contact"
else -> "Contact"
}
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
},
actions = {
if (uiState is ContactDetailUiState.Success) {
if (isEditing) {
// Done button exits edit mode
TextButton(onClick = { isEditing = false }) {
Text("Done")
}
} else {
// Edit button opens edit mode
IconButton(onClick = { isEditing = true }) {
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
}
}
}
}
)
},
floatingActionButton = {
// 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() }) {
Icon(Icons.Default.Add, contentDescription = "Add payment address")
}
}
}
) { innerPadding ->
when (val state = uiState) {
is ContactDetailUiState.Loading -> {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ContactDetailUiState.NotFound -> {
Box(
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
"Contact not found",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
is ContactDetailUiState.Success -> {
ContactDetailContent(
state = state,
isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() },
onEditAddress = { addr -> editingAddress = addr },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
)
}
}
}
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
if (pendingDelete) {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Delete contact") },
text = { Text("This contact and all their addresses will be permanently removed.") },
confirmButton = {
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) {
Text(strings.cancel)
}
}
)
}
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 ─────────────────────────────────────────────────
if (showAddAddressSheet) {
AddAddressSheet(
onSave = { type, address, label, makeDefault ->
viewModel.addAddress(type, address, label, makeDefault)
viewModel.closeAddAddressSheet()
},
onDismiss = { viewModel.closeAddAddressSheet() }
)
}
// ── 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 ──────────────────────────────────────────────────
if (showEditAliasSheet) {
val currentAlias = (uiState as? ContactDetailUiState.Success)
?.data?.contact?.localAlias ?: ""
EditAliasSheet(
currentAlias = currentAlias,
onSave = { newAlias ->
viewModel.updateAlias(newAlias)
viewModel.closeEditAliasSheet()
},
onDismiss = { viewModel.closeEditAliasSheet() }
)
}
}
// ── Main content ───────────────────────────────────────────────────────────────
@Composable
private fun ContactDetailContent(
state: ContactDetailUiState.Success,
isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit,
onEditAddress: (PaymentAddressEntity) -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier
) {
val contact = state.data.contact
val addresses = state.data.addresses
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
) {
// ── Avatar + name hero ─────────────────────────────────────────────
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "?"
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(72.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(Modifier.height(12.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge
)
contact.nip05?.let { nip05 ->
Text(
text = nip05,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// ── Payment addresses section ─────────────────────────────────────
item {
DetailSection(title = "Payment addresses") {
if (addresses.isEmpty()) {
Text(
text = "No addresses added yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
addresses.forEachIndexed { index, addr ->
AddressRow(
address = addr,
isEditing = isEditing,
onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) },
onEdit = { onEditAddress(addr) }
)
if (index < addresses.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
contact.about?.let { about ->
item {
DetailSection(title = "About") {
DetailRow(label = "Bio", value = about, maxLines = 4)
}
}
}
// ── 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")
}
}
}
}
}
}
// ── Address row ────────────────────────────────────────────────────────────────
@Composable
private fun AddressRow(
address: PaymentAddressEntity,
isEditing: Boolean,
onPay: () -> Unit,
onRemove: () -> Unit,
onSetDefault: () -> Unit,
onEdit: () -> Unit
) {
val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
PaymentAddressType.LNURL.name -> "LNURL"
PaymentAddressType.ON_CHAIN.name -> "On-chain"
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
PaymentAddressType.BIP353.name -> "BIP-353"
else -> address.type
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = !isEditing,
onClickLabel = "Pay with this address"
) { onPay() }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// ── Label + value ─────────────────────────────────────────────
Column(modifier = Modifier.weight(1f)) {
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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
)
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove address",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// ── Add address sheet ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddAddressSheet(
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val addressFocus = remember { FocusRequester() }
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf("") }
var label by remember { mutableStateOf("") }
var makeDefault by remember { mutableStateOf(false) }
val canSave = address.isNotBlank()
LaunchedEffect(Unit) { addressFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
// Type chips
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
label = { Text("LNURL") }
)
}
// Address field
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"Lightning Address" else "LNURL"
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
"user@domain.com" else "LNURL1..."
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(addressFocus)
)
// Optional label
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label (optional)") },
placeholder = { Text("e.g. personal, savings") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
}
),
modifier = Modifier.fillMaxWidth()
)
// Set as default toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Set as default",
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = makeDefault,
onCheckedChange = { makeDefault = it }
)
}
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = {
focusManager.clearFocus()
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
},
enabled = canSave
) { Text("Save") }
}
}
}
}
// ── Edit 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 ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditAliasSheet(
currentAlias: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var alias by remember { mutableStateOf(currentAlias) }
val canSave = alias.isNotBlank()
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Edit name", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = alias,
onValueChange = { alias = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(alias)
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = { focusManager.clearFocus(); onSave(alias) },
enabled = canSave
) { Text("Save") }
}
}
}
}
@@ -0,0 +1,124 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
sealed class ContactDetailUiState {
data object Loading : ContactDetailUiState()
data object NotFound : ContactDetailUiState()
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
}
class ContactDetailViewModel(
private val contactId: String,
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
.map { contact ->
if (contact == null) ContactDetailUiState.NotFound
else ContactDetailUiState.Success(contact)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactDetailUiState.Loading
)
// ── Delete contact ─────────────────────────────────────────────────────
private val _pendingDelete = MutableStateFlow(false)
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
fun requestDelete() { _pendingDelete.value = true }
fun cancelDelete() { _pendingDelete.value = false }
fun confirmDelete(onDeleted: () -> Unit) {
viewModelScope.launch {
repo.deleteContact(contactId)
_pendingDelete.value = false
onDeleted()
}
}
// ── Edit alias ─────────────────────────────────────────────────────────
fun updateAlias(newAlias: String) {
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
}
// ── Payment addresses ──────────────────────────────────────────────────
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = type,
address = address.trim(),
label = label?.trim()?.takeIf { it.isNotEmpty() },
makeDefault = makeDefault
)
}
}
fun updateAddress(
addressId: String,
type: PaymentAddressType,
address: String,
label: String?,
makeDefault: Boolean
) {
viewModelScope.launch {
repo.updateAddress(addressId, type, address, label, makeDefault)
}
}
fun removeAddress(addressId: String) {
viewModelScope.launch { repo.removeAddress(addressId) }
}
fun setDefaultAddress(address: PaymentAddressEntity) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = PaymentAddressType.valueOf(address.type),
address = address.address,
label = address.label,
makeDefault = true
)
}
}
// ── Add address sheet state ────────────────────────────────────────────
private val _showAddAddressSheet = MutableStateFlow(false)
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
// ── Edit alias sheet state ─────────────────────────────────────────────
private val _showEditAliasSheet = MutableStateFlow(false)
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
class Factory(
private val contactId: String,
private val repo: ContactRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactDetailViewModel(contactId, repo) as T
}
}
@@ -0,0 +1,151 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactPickerSheet(
contacts: List<ContactWithAddresses>,
onContactSelected: (contactId: String) -> Unit,
onCreateNew: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var search by remember { mutableStateOf("") }
val filtered = remember(contacts, search) {
if (search.isBlank()) contacts
else contacts.filter { c ->
val name = c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: ""
name.contains(search, ignoreCase = true)
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
// Search field
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Search") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
keyboardType = KeyboardType.Text,
autoCorrectEnabled = false
),
modifier = Modifier.fillMaxWidth()
)
// Contact list
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(filtered, key = { it.contact.id }) { item ->
val displayName = item.contact.localAlias
?: item.contact.displayName
?: item.contact.name
?: "Unknown"
ListItem(
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = item.addresses.firstOrNull()?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
},
modifier = Modifier.clickable {
onContactSelected(item.contact.id)
}
)
}
if (filtered.isEmpty() && search.isNotBlank()) {
item {
Text(
text = "No contacts match \"$search\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
// Create new contact button
HorizontalDivider()
TextButton(
onClick = onCreateNew,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Create new contact")
}
}
}
}
@@ -0,0 +1,314 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
viewModel: ContactsViewModel,
onContactClick: (contactId: String) -> Unit,
onBack: () -> Unit
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCreateSheet by remember { mutableStateOf(false) }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contacts") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
},
actions = {
IconButton(onClick = {
searchVisible = !searchVisible
if (!searchVisible) searchQuery = ""
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search contacts"
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add contact")
}
}
) { innerPadding ->
val focusRequester = remember { FocusRequester() }
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
LaunchedEffect(searchVisible) {
if (searchVisible) {
// kotlinx.coroutines.delay(50)
focusRequester.requestFocus()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
if (searchVisible) {
ContactsSearchBar(
query = searchQuery,
focusRequester = focusRequester,
clipboard = clipboard,
scope = scope,
onQueryChange = { searchQuery = it },
onClear = { searchQuery = "" }
)
HorizontalDivider()
}
when (val state = uiState) {
is ContactsUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
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) {
CreateContactSheet(
onSave = { name, addressType, address ->
viewModel.createContact(name, addressType, address)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
}
}
}
// ── Empty state ──────────────────────────────────────────────────────────────
@Composable
private fun ContactsEmptyContent(
onAdd: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
Text(
text = "No contacts yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
text = "Tap + to add your first contact",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add contact")
}
}
}
// ── 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
private fun ContactRow(
item: ContactWithAddresses,
onClick: () -> Unit
) {
val contact = item.contact
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
val primaryAddress = item.addresses
.firstOrNull { it.isDefault }
?: item.addresses.firstOrNull()
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = primaryAddress?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
)
}
@@ -0,0 +1,60 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed class ContactsUiState {
data object Loading : ContactsUiState()
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
}
class ContactsViewModel(
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
.map { ContactsUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactsUiState.Loading
)
fun createContact(
name: String,
addressType: PaymentAddressType?,
address: String?
) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
repo.createContact(
localAlias = name.trim(),
lnAddress = lnAddress,
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
)
}
}
fun deleteContact(id: String) {
viewModelScope.launch {
repo.deleteContact(id)
}
}
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactsViewModel(repo) as T
}
}
@@ -0,0 +1,160 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateContactSheet(
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
onDismiss: () -> Unit,
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
initialAddress: String? = null
) {
val strings = LocalAppStrings.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
var name by remember { mutableStateOf("") }
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf(initialAddress ?: "") }
val nameFocus = remember { FocusRequester() }
val canSave = name.isNotBlank()
val addressLabel = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
PaymentAddressType.LNURL -> "LNURL"
else -> "Address"
}
val addressPlaceholder = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
PaymentAddressType.LNURL -> "LNURL1..."
else -> ""
}
LaunchedEffect(Unit) { nameFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New contact",
style = MaterialTheme.typography.titleMedium
)
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(nameFocus)
)
// Address type toggle
Text(
text = "Payment address (optional)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = {
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
address = ""
},
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = {
selectedType = PaymentAddressType.LNURL
address = ""
},
label = { Text("LNURL") }
)
}
// Address field
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) {
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
}
}
),
modifier = Modifier.fillMaxWidth()
)
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(strings.cancel)
}
Button(
onClick = {
focusManager.clearFocus()
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
},
enabled = canSave
) {
Text("Save")
}
}
}
}
}
@@ -14,7 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -96,6 +96,17 @@ class FiatViewModel(
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 ─────
fun onBalanceRefreshed() {
viewModelScope.launch { fetchAndApplyFiatRate() }
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.util.formatEpochShort
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -61,14 +62,14 @@ internal fun FilterBottomSheet(
) {
// ── Sheet title ───────────────────────────────────────────────────
Text(
text = strings.filterSheetTitle, // ← "Filter payments"
text = strings.history.filterSheetTitle, // ← "Filter payments"
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(20.dp))
// ── Direction ─────────────────────────────────────────────────────
Text(
text = strings.filterSectionDirection, // ← "Direction"
text = strings.history.filterSectionDirection, // ← "Direction"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -76,9 +77,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DirectionFilter.entries.forEach { option ->
val label = when (option) {
DirectionFilter.ALL -> strings.filterDirectionAll // ← "All"
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming"
DirectionFilter.ALL -> strings.history.filterDirectionAll // ← "All"
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
DirectionFilter.INCOMING -> strings.history.filterDirectionIncoming // ← "Incoming"
}
FilterChip(
selected = currentFilter.direction == option,
@@ -91,7 +92,7 @@ internal fun FilterBottomSheet(
// ── Status ────────────────────────────────────────────────────────
Text(
text = strings.filterSectionStatus, // ← "Status"
text = strings.history.filterSectionStatus, // ← "Status"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -104,9 +105,9 @@ internal fun FilterBottomSheet(
label = {
Text(
when (option) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
}
)
}
@@ -118,7 +119,7 @@ internal fun FilterBottomSheet(
if (availableTypes.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Text(
text = strings.filterSectionType, // ← "Type"
text = strings.history.filterSectionType, // ← "Type"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -137,7 +138,7 @@ internal fun FilterBottomSheet(
// ── Amount (sats) ─────────────────────────────────────────────────
Text(
text = strings.filterSectionAmount, // ← "Amount (sats)"
text = strings.history.filterSectionAmount, // ← "Amount (sats)"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -158,8 +159,8 @@ internal fun FilterBottomSheet(
OutlinedTextField(
value = minText,
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMin_label) }, // ← "Min"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
label = { Text(strings.history.filterAmountMin_label) }, // ← "Min"
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
@@ -168,8 +169,8 @@ internal fun FilterBottomSheet(
OutlinedTextField(
value = maxText,
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMax_label) }, // ← "Max"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
label = { Text(strings.history.filterAmountMax_label) }, // ← "Max"
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
@@ -179,7 +180,7 @@ internal fun FilterBottomSheet(
// ── Date ──────────────────────────────────────────────────────────
Text(
text = strings.filterSectionDate, // ← "Date"
text = strings.history.filterSectionDate, // ← "Date"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -189,9 +190,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DatePreset.entries.forEach { preset ->
val label = when (preset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
}
FilterChip(
selected = currentFilter.datePreset == preset,
@@ -219,8 +220,8 @@ internal fun FilterBottomSheet(
) {
Text(
currentFilter.minCreatedAt
?.let { formatEpochShort(it) }
?: strings.filterDateFrom_label // ← "From"
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateFrom_label // ← "From"
)
}
Text("", style = MaterialTheme.typography.bodyLarge)
@@ -230,8 +231,8 @@ internal fun FilterBottomSheet(
) {
Text(
currentFilter.maxCreatedAt
?.let { formatEpochShort(it) }
?: strings.filterDateTo_label // ← "To"
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.history.filterDateTo_label // ← "To"
)
}
}
@@ -247,11 +248,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000)
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
showFromPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK"
}) { Text(strings.history.filterDialogOk) } // ← "OK"
},
dismissButton = {
TextButton(onClick = { showFromPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel"
Text(strings.history.filterDialogCancel) // ← "Cancel"
}
}
) { DatePicker(state = pickerState) }
@@ -269,11 +270,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
showToPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK"
}) { Text(strings.history.filterDialogOk) } // ← "OK"
},
dismissButton = {
TextButton(onClick = { showToPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel"
Text(strings.history.filterDialogCancel) // ← "Cancel"
}
}
) { DatePicker(state = pickerState) }
@@ -1,185 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
// ── Amount + fiat column ─────────────────────────────────────────────────────
@Composable
internal fun AmountWithFiatColumn(
amountSat : Long,
isOutgoing : Boolean,
amountColor : Color,
fiatLine : String?,
style : TextStyle = MaterialTheme.typography.bodyMedium,
horizontalAlignment: Alignment.Horizontal = Alignment.End
) {
val prefix = if (isOutgoing) "" else "+"
Column(horizontalAlignment = horizontalAlignment) {
Text(
text = "$prefix$amountSat sats",
style = style.copy(fontFamily = FontFamily.Monospace),
color = amountColor
)
if (fiatLine != null) {
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
Text(
text = fiatLine,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
}
}
}
// ── Status chip ──────────────────────────────────────────────────────────────
@Composable
internal fun StatusChip(status: String) {
val semantic = semanticColors()
val strings = LocalAppStrings.current // or however you access Lyricist strings
data class ChipStyle(
val containerColor: Color,
val contentColor: Color,
val label: String,
)
val style = when (status.lowercase()) {
"success", "complete", "paid" -> ChipStyle(
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
)
"pending", "in_flight", "inflight" -> ChipStyle(
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
)
"failed", "error", "expired" -> ChipStyle(
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
)
else -> ChipStyle(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant,
strings.statusUnknown,
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = style.containerColor,
contentColor = style.contentColor,
) {
Text(
text = style.label,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Section card wrapper ─────────────────────────────────────────────────────
@Composable
internal fun DetailSection(
title : String,
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
// ── Label / value row ────────────────────────────────────────────────────────
@Composable
internal fun DetailRow(
label : String,
value : String,
monospace : Boolean = false,
copyable : Boolean = false,
onCopy : (() -> Unit)? = null,
maxLines : Int = Int.MAX_VALUE,
valueColor : Color = Color.Unspecified,
textDecoration : TextDecoration? = null,
onValueClick : (() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(0.35f)
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.weight(0.65f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = value,
style = if (monospace)
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
else
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
color = valueColor,
modifier = Modifier
.weight(1f, fill = false)
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
if (copyable) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy $label",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trailingContent?.invoke()
}
}
}
@@ -56,44 +56,47 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.HistoryState
import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch
// ── List screen ──────────────────────────────────────────────────────────────
// ── List screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(
vm : HistoryViewModel,
onClose : () -> Unit,
onPaymentClick : (PaymentRecord) -> Unit
onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
) {
val strings = LocalAppStrings.current
val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val strings = LocalAppStrings.current
val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
val filter by vm.filter.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val listState = rememberLazyListState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
// they are now embedded in each PaymentFeedItem
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchInfoSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.isActive
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.historyTitle) }, // ← "History"
title = { Text(strings.history.historyTitle) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.historyClose // ← "Close"
contentDescription = strings.history.historyClose
)
}
},
@@ -107,7 +110,7 @@ fun HistoryScreen(
}) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = strings.historySearchPayments // ← "Search payments"
contentDescription = strings.history.historySearchPayments
)
}
}
@@ -117,7 +120,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = strings.historyFilterPayments // ← "Filter payments"
contentDescription = strings.history.historyFilterPayments
)
}
}
@@ -138,7 +141,7 @@ fun HistoryScreen(
.fillMaxSize()
.padding(padding)
) {
// ── Search bar ───────────────────────────────────────────────────
// ── Search bar ─────────────────────────────────────────────────
if (searchVisible) {
SearchBar(
query = filter.searchQuery,
@@ -152,7 +155,7 @@ fun HistoryScreen(
HorizontalDivider()
}
// ── Active filter chips ──────────────────────────────────────────
// ── Active filter chips ────────────────────────────────────────
val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) {
ActiveFilterChips(
@@ -170,14 +173,84 @@ fun HistoryScreen(
)
}
// ── Content ──────────────────────────────────────────────────────
// ── Content ────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
// ── List — always rendered when state is Success, never unmounted ──────
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 -> {
Column(
modifier = Modifier.align(Alignment.Center),
@@ -190,95 +263,33 @@ fun HistoryScreen(
)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) {
Text(strings.historyRetry) // ← "Retry"
}
}
}
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.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,
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
)
}
}
}
}
Text(strings.history.historyRetry)
}
}
}
is HistoryState.Success -> { /* handled above */ }
}
}
}
}
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
if (filterSheetVisible) {
FilterBottomSheet(
currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
currentFilter = filter,
availableTypes = availableTypes,
sheetState = sheetState,
onDismiss = dismissFilterSheet,
onDirectionSelected = { direction ->
vm.setDirectionFilter(direction)
dismissFilterSheet()
},
onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
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() }
)
}
@@ -288,7 +299,7 @@ fun HistoryScreen(
)
}
// ── Search bar ───────────────────────────────────────────────────────────────
// ── Search bar ───────────────────────────────────────────────────────────────
@Composable
private fun SearchBar(
query : String,
@@ -309,13 +320,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = strings.historySearchHelp // ← "Search help"
contentDescription = strings.history.historySearchHelp
)
}
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions"
placeholder = { Text(strings.history.historySearchPlaceholder) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -336,7 +347,7 @@ private fun SearchBar(
}) {
Icon(
imageVector = Icons.Default.ContentPaste,
contentDescription = strings.historySearchPaste // ← "Paste"
contentDescription = strings.history.historySearchPaste
)
}
IconButton(
@@ -345,7 +356,7 @@ private fun SearchBar(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.historySearchClear, // ← "Clear search"
contentDescription = strings.history.historySearchClear,
tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
@@ -353,22 +364,22 @@ private fun SearchBar(
}
}
// ── Active filter chips ──────────────────────────────────────────────────────
// ── Active filter chips ──────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ActiveFilterChips(
filter : PaymentFilter,
searchActive : Boolean,
onClearDirection: () -> Unit,
onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit,
onClearAmount : () -> Unit,
onClearDate : () -> Unit,
onClearSearch : () -> Unit
filter : PaymentFilter,
searchActive : Boolean,
onClearDirection : () -> Unit,
onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit,
onClearAmount : () -> Unit,
onClearDate : () -> Unit,
onClearSearch : () -> Unit
) {
val strings = LocalAppStrings.current
FlowRow(
modifier = Modifier
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -381,15 +392,15 @@ private fun ActiveFilterChips(
label = {
Text(
when (filter.direction) {
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
else -> strings.filterDirectionIncoming // ← "Incoming"
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
else -> strings.history.filterDirectionIncoming
}
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDirection, // ← "Clear direction filter"
contentDescription = strings.history.filterClearDirection,
modifier = Modifier.size(16.dp)
)
}
@@ -402,16 +413,16 @@ private fun ActiveFilterChips(
label = {
Text(
when (s) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
StatusFilter.PENDING -> strings.history.filterStatusPending
StatusFilter.FAILED -> strings.history.filterStatusFailed
}
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter"
contentDescription = strings.history.filterRemoveStatus,
modifier = Modifier.size(16.dp)
)
}
@@ -425,7 +436,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveType, // ← "Remove type filter"
contentDescription = strings.history.filterRemoveType,
modifier = Modifier.size(16.dp)
)
}
@@ -434,11 +445,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null ->
strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats"
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
filter.minAmountSat != null ->
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
strings.history.filterAmountMin(filter.minAmountSat)
else ->
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
strings.history.filterAmountMax(filter.maxAmountSat!!)
}
InputChip(
selected = true,
@@ -447,7 +458,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.filterClearAmount, // ← "Clear amount filter"
contentDescription = strings.history.filterClearAmount,
modifier = Modifier.size(16.dp)
)
}
@@ -457,19 +468,23 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
null -> when {
minCreatedAt != null && maxCreatedAt != null ->
strings.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt),
formatEpochShort(maxCreatedAt)
strings.history.filterDateRange(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
)
minCreatedAt != null ->
strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
strings.history.filterDateFrom(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
)
else ->
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
strings.history.filterDateUntil(
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
)
}
}
InputChip(
@@ -479,7 +494,7 @@ private fun ActiveFilterChips(
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDate, // ← "Clear date filter"
contentDescription = strings.history.filterClearDate,
modifier = Modifier.size(16.dp)
)
}
@@ -489,11 +504,11 @@ private fun ActiveFilterChips(
InputChip(
selected = true,
onClick = onClearSearch,
label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.historyClearSearch, // ← "Clear search"
contentDescription = strings.history.historyClearSearch,
modifier = Modifier.size(16.dp)
)
}
@@ -4,36 +4,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
// ── Pure helpers ─────────────────────────────────────────────────────────────
internal fun formatEpochShort(epoch: Long): String =
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
.withZone(java.time.ZoneId.systemDefault())
.format(java.time.Instant.ofEpochSecond(epoch))
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
}
// Fallback: only reached for payments not yet written through Room
return runCatching {
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime))
}.getOrDefault(rawTime)
}
internal fun isPendingStatus(status: String): Boolean =
status.lowercase() in setOf("pending", "in_flight", "inflight")
internal fun isFailedStatus(status: String): Boolean =
status.lowercase() in setOf("failed", "error", "expired")
// ── Composable helpers ───────────────────────────────────────────────────────
import com.bitcointxoko.gudariwallet.util.isFailedStatus
import com.bitcointxoko.gudariwallet.util.isPendingStatus
@Composable
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
@@ -5,199 +5,464 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.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.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel"
@OptIn(FlowPreview::class)
class HistoryViewModel(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val feedRepository : PaymentFeedRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
) : 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(
repo = repo,
paymentCache = paymentCache,
scope = viewModelScope,
repo = repo,
paymentCache = paymentCache,
scope = viewModelScope,
onNewPayments = { payments -> enricher.enrich(payments) }
)
// ── Optimistic insert (called after a successful send) ─────────────────────
fun primePayment(record: PaymentRecord) {
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(
repo = repo,
aliasRepo = aliasRepo,
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
scope = viewModelScope,
onEnriched = { paymentHash, pubkey, alias ->
scope = viewModelScope,
onEnriched = { 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
fun refresh() = syncManager.refresh()
fun refresh() = syncManager.refresh()
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(
repo = repo,
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
scope = viewModelScope
scope = viewModelScope
)
val detailState: StateFlow<DetailState> = detailDelegate.state
fun loadDetail(checkingId: String, record: PaymentRecord? = null) =
detailDelegate.load(checkingId, record)
fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
detailDelegate.load(paymentHash, record)
fun clearDetail() = detailDelegate.clear()
// ── Filter state ──────────────────────────────────────────────────────────
// ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) }
fun setSearchQuery(q: String) {
_filter.update { it.copy(searchQuery = q.trim()) }
}
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 ->
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)
}
}
fun toggleTypeFilter(type: String) {
_filter.update { f ->
val updated = if (type in f.types) f.types - type else f.types + type
f.copy(types = updated)
}
}
fun setAmountFilter(min: Long?, max: Long?) {
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
}
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
}
fun applyDatePreset(preset: DatePreset) {
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt
val (min, max) = preset.toEpochSecondRange()
setDateFilter(min, max, preset)
}
fun clearDateFilter() {
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
}
fun findPayment(checkingId: String): PaymentRecord? {
val fromFiltered = (filteredState.value as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
if (fromFiltered != null) return fromFiltered
return (state.value as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
fun toggleContactFilter(contactId: String) {
_filter.update { f ->
val updated = if (contactId in f.contactIds)
f.contactIds - contactId else f.contactIds + contactId
f.copy(contactIds = updated)
}
}
fun clearContactFilter() {
_filter.update { it.copy(contactIds = emptySet()) }
}
// ── Room-backed query results ──────────────────────────────────────────────
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
// 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)
// ── 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()
// ── 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())
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)
// ── Available type filter chips ────────────────────────────────────────────
val availableTypes: StateFlow<List<String>> = feedItems
.map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
// ── In-memory filter logic ────────────────────────────────────────────────
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
// ── 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.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.extra?.tag in f.types }
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)
}
}
// ── Init ──────────────────────────────────────────────────────────────────
// ── 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>> =
contactRepo.observeAllContacts()
.map { contacts ->
contacts.associate { c ->
c.contact.id to (c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: "Unknown")
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()
)
// ── Per-payment contact queries ────────────────────────────────────────────
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
contactRepo.observeContactsForPayment(paymentHash)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
contactRepo.observeAllContacts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
// ── Contact assignment ─────────────────────────────────────────────────────
fun assignContact(paymentHash: String, contactId: String) {
Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) }
}
fun unassignContact(paymentHash: String, contactId: String) {
viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) }
}
fun saveContactAndAssign(
paymentHash: String,
name: String,
addressType: PaymentAddressType?,
address: String?
) {
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
val contact = contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
contactRepo.linkPaymentToContact(paymentHash, contact.id)
}
}
// ── Init ───────────────────────────────────────────────────────────────────
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 {
combine(
_filter.map { it.searchQuery }.debounce(300),
_filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter
) { _, f -> f }
.distinctUntilChanged()
.collect { f ->
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
} else {
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " +
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
"amt=${f.minAmountSat}${f.maxAmountSat} " +
"date=${f.minCreatedAt}${f.maxCreatedAt}")
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
return@collect
}
// Snapshot the contact summaries once per query — no new DAO needed.
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
// resolve contact names without per-row DB calls.
val contactSnapshot: Map<String, List<ContactSummary>> =
contactRepo.observePaymentContactSummaries()
.first()
.groupBy(
keySelector = { it.paymentHash },
valueTransform = {
ContactSummary(
id = it.contactId,
displayName = it.displayName
)
}
)
// ── Explicit contact-ID filter (highest priority) ──────────
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 {
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()
}
}
}
// ── 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(
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit: StateFlow<Double?>
private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val feedRepository : PaymentFeedRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
"Unknown ViewModel class: ${modelClass.name}"
}
@Suppress("UNCHECKED_CAST")
return HistoryViewModel(
repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache,
contactRepo = contactRepo,
feedRepository = feedRepository,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit
) as T
}
}
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.DetailState
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
class PaymentDetailDelegate(
private val repo : WalletRepository,
private val paymentCache : PaymentCacheRepository,
private val aliasRepo : NodeAliasRepository,
private val scope : CoroutineScope
) {
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
fun load(checkingId: String, record: PaymentRecord? = null) {
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)
return
}
_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)
} else {
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner")
Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
DetailState.Loading
}
scope.launch {
val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb)
_state.value = DetailState.Success(fromDb)
Timber.d("DETAIL [DB] $checkingId — served from Room")
val record = fromDb.details
val enriched = if (
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
}
runCatching { repo.getPaymentDetail(checkingId) }
.onSuccess { detail ->
Timber.d("DETAIL [NET] $checkingId — fetched from network")
Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
paymentCache.saveDetail(checkingId, detail)
_state.value = DetailState.Success(detail)
}
.onFailure { e ->
Timber.w("DETAIL [ERR] $checkingId${e.message}")
Timber.tag(TAG).w("DETAIL [ERR] $checkingId${e.message}")
if (_state.value !is DetailState.Partial) {
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
}
}
}
}
fun clear() { _state.value = DetailState.Idle }
}
@@ -6,24 +6,30 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -33,8 +39,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
@@ -42,43 +50,88 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
import com.bitcointxoko.gudariwallet.ui.DetailState
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.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch
// ── Detail screen ────────────────────────────────────────────────────────────
// ── Detail screen ────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentDetailScreen(
payment : PaymentRecord,
item : PaymentFeedItem, // ← was: payment: PaymentRecord
vm : HistoryViewModel,
onBack : () -> Unit
) {
val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val strings = LocalAppStrings.current
val detailState by vm.detailState.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() } }
// 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) {
is DetailState.Success -> {
val detail = s.detail.details
when {
detail == null -> payment
else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
detail == null -> item.toBaseRecord()
else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
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
else -> payment
is DetailState.Partial -> s.record.let { r ->
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(item.paymentHash) {
vm.contactsForPayment(item.paymentHash)
}.collectAsStateWithLifecycle()
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
var showContactPicker by remember { mutableStateOf(false) }
var showCreateContact by remember { mutableStateOf(false) }
// Best-effort lnAddress to pre-fill CreateContactSheet
val paymentLnAddress = remember(enriched) {
enriched.extra?.comment
?.takeIf { it.contains("@") && !it.contains(" ") }
}
Scaffold(
@@ -86,15 +139,15 @@ fun PaymentDetailScreen(
TopAppBar(
title = {
Text(
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment"
else strings.detailIncomingTitle // ← "Incoming payment"
if (item.isOutgoing) strings.history.detailOutgoingTitle
else strings.history.detailIncomingTitle
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.detailBack // ← "Back"
contentDescription = strings.history.detailBack
)
}
}
@@ -116,7 +169,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = strings.detailCouldNotLoad, // ← "Could not load full details"
text = strings.history.detailCouldNotLoad,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
@@ -125,34 +178,86 @@ fun PaymentDetailScreen(
PaymentDetailContent(
payment = enriched,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit
fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(item.paymentHash, contactId)
}
)
}
}
else -> {
PaymentDetailContent(
payment = enriched,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
modifier = Modifier.padding(padding)
payment = enriched,
fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(item.paymentHash, contactId)
},
modifier = Modifier.padding(padding)
)
}
}
}
// ── Contact picker sheet ───────────────────────────────────────────────────
if (showContactPicker) {
ContactPickerSheet(
contacts = allContacts,
onContactSelected = { contactId ->
vm.assignContact(item.paymentHash, contactId)
showContactPicker = false
},
onCreateNew = {
showContactPicker = false
showCreateContact = true
},
onDismiss = { showContactPicker = false }
)
}
// ── Create & assign contact sheet ──────────────────────────────────────────
if (showCreateContact) {
val addressType = if (paymentLnAddress != null)
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
CreateContactSheet(
initialAddressType = addressType,
initialAddress = paymentLnAddress,
onSave = { name, type, address ->
vm.saveContactAndAssign(item.paymentHash, name, type, address)
showCreateContact = false
},
onDismiss = { showCreateContact = false }
)
}
}
// ── 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
private fun PaymentDetailContent(
payment : PaymentRecord,
fiatCurrency : String?,
fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(
isPending = payment.pending,
isFailed = payment.status == "failed",
isOutgoing = payment.isOutgoing
)
val context = LocalContext.current
val clipboard = LocalClipboard.current
@@ -178,7 +283,7 @@ private fun PaymentDetailContent(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Hero amount ──────────────────────────────────────────────────────
// ── Hero amount ────────────────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
@@ -187,7 +292,7 @@ private fun PaymentDetailContent(
) {
Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward,
else Icons.Default.ArrowDownward,
contentDescription = null,
tint = amountColor,
modifier = Modifier.size(36.dp)
@@ -202,16 +307,13 @@ private fun PaymentDetailContent(
horizontalAlignment = Alignment.CenterHorizontally
)
if (payment.feeSat > 0) {
val ppm = feePpm(
val ppm = feePpm(
amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat)
)
// Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
payment.feeSat, fiatPart, ppmPart
)
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
Spacer(Modifier.height(4.dp))
Text(
text = feeLabel,
@@ -225,39 +327,129 @@ private fun PaymentDetailContent(
}
}
// ── Basic info ───────────────────────────────────────────────────────
// ── Contact section ────────────────────────────────────────────────────
item {
DetailSection(title = strings.detailSectionDetails) { // ← "Details"
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date"
if (linkedContacts.isEmpty()) {
OutlinedButton(
onClick = onAssignContact,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Assign contact")
// Text(strings.history.detailAssignContact)
}
} else {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Contacts",
// text = strings.history.detailContactsTitle,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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(
onClick = { onUnassignContact(contact.id) },
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
// contentDescription = strings.history.detailRemoveContact,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (index < linkedContacts.lastIndex) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
// ── Basic info ─────────────────────────────────────────────────────────
item {
DetailSection(title = strings.history.detailSectionDetails) {
DetailRow(
strings.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—"
strings.history.detailRowDate,
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
)
DetailRow(
strings.history.detailRowMemo,
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
)
if (payment.extra?.tag != null) {
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type"
DetailRow(strings.history.detailRowType, payment.extra.tag)
}
if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment"
DetailRow(strings.history.detailRowComment, payment.extra.comment)
}
}
}
// ── Success action (LNURL) ───────────────────────────────────────────
// ── Success action (LNURL) ─────────────────────────────────────────────
payment.extra?.successAction?.let { action ->
item {
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action"
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message"
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL"
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description"
DetailSection(title = strings.history.detailSectionSuccessAction) {
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
}
}
}
// ── Technical ────────────────────────────────────────────────────────
// ── Technical ────────────────────────────────────────────────────────
item {
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical"
DetailSection(title = strings.history.detailSectionTechnical) {
DetailRow(
label = strings.detailRowPaymentHash, // ← "Payment Hash"
label = strings.history.detailRowPaymentHash,
value = payment.paymentHash,
monospace = true,
copyable = true,
@@ -271,7 +463,7 @@ private fun PaymentDetailContent(
)
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow(
label = strings.detailRowPreimage, // ← "Preimage"
label = strings.history.detailRowPreimage,
value = payment.preimage,
monospace = true,
copyable = true,
@@ -288,37 +480,21 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow(
label = strings.detailRowDestination, // ← "Destination"
value = label,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = {
label = strings.history.detailRowDestination,
value = label,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
},
trailingContent = {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("node_id", pubkey))
)
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CopyIconButton(label = "node_id", value = pubkey)
}
)
}
if (!payment.bolt11.isNullOrBlank()) {
DetailRow(
label = strings.detailRowBolt11, // ← "BOLT11"
label = strings.history.detailRowBolt11,
value = payment.bolt11,
monospace = true,
copyable = true,
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.history
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import java.time.DayOfWeek
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
@@ -14,7 +15,8 @@ data class PaymentFilter(
val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null
val datePreset : DatePreset? = null,
val contactIds : Set<String> = emptySet()
)
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
@@ -29,12 +31,16 @@ val PaymentFilter.isActive: Boolean
|| maxAmountSat != null
|| minCreatedAt != null
|| maxCreatedAt != null
|| contactIds.isNotEmpty()
fun PaymentFilter.needsRoomQuery(): Boolean =
searchQuery.isNotBlank() ||
statuses.isNotEmpty() ||
minAmountSat != null || maxAmountSat != null ||
minCreatedAt != null || maxCreatedAt != null
minAmountSat != null ||
maxAmountSat != null ||
minCreatedAt != null ||
maxCreatedAt != null ||
contactIds.isNotEmpty()
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
@@ -60,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
if (f.types.isEmpty()) list
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,29 +22,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
@Composable
internal fun PaymentRow(
payment : PaymentRecord,
fiatCurrency : String?,
fiatSatsPerUnit : Double?,
onClick : () -> Unit
item : PaymentFeedItem,
onClick : () -> Unit
) {
val strings = LocalAppStrings.current
val isPending = isPendingStatus(payment.status)
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 strings = LocalAppStrings.current
val semantic = semanticColors()
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
val amountColor = paymentAmountColor(
isPending = item.isPending,
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
}
@@ -56,45 +58,47 @@ internal fun PaymentRow(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent"
else strings.paymentRowReceivedCD, // ← "Received"
tint = amountColor,
modifier = Modifier.size(20.dp)
imageVector = icon,
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
else strings.history.paymentRowReceivedCD,
tint = amountColor,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = payment.memo?.takeIf { it.isNotBlank() }
?: strings.paymentRowNoMemo, // ← "No memo"
text = item.contactName // contact name first
?: item.memo?.takeIf { it.isNotBlank() } // then memo
?: strings.history.paymentRowNoMemo, // then fallback
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time)
val formattedTime = remember(item.createdAt ?: item.time) {
formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
}
Text(
text = formattedTime,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val statusLabel = when {
isPending -> strings.paymentRowPending
isFailed -> strings.paymentRowFailed
else -> null
item.isPending -> strings.history.paymentRowPending
item.isFailed -> strings.history.paymentRowFailed
else -> null
}
val statusColor = when {
isPending -> semantic.warningContainer
isFailed -> semantic.errorContainer
else -> semantic.successContainer // fallback, won't be used
item.isPending -> semantic.warningContainer
item.isFailed -> semantic.errorContainer
else -> semantic.successContainer
}
val statusContentColor = when {
isPending -> semantic.onWarningContainer
isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer
item.isPending -> semantic.onWarningContainer
item.isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer
}
if (statusLabel != null) {
Spacer(Modifier.height(4.dp))
@@ -113,10 +117,25 @@ internal fun PaymentRow(
}
Spacer(Modifier.width(12.dp))
AmountWithFiatColumn(
amountSat = payment.amountSat,
isOutgoing = payment.isOutgoing,
amountSat = item.amountSat,
isOutgoing = item.isOutgoing,
amountColor = amountColor,
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
}
}
@@ -159,8 +159,7 @@ class PaymentSyncManager(
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
if (reachedOverlap || reachedEnd) {
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " +
if (reachedOverlap) "stopped at known record" else "reached end of history")
Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
break
}
@@ -33,45 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
) {
Text(
text = strings.searchInfoTitle, // ← "What can I search for?"
text = strings.history.searchInfoTitle, // ← "What can I search for?"
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
Text(
text = strings.searchInfoSubtitle, // ← "Type any part of the following…"
text = strings.history.searchInfoSubtitle, // ← "Type any part of the following…"
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
SearchInfoItem(
title = strings.searchInfoMemoTitle, // ← "Memo"
description = strings.searchInfoMemoDesc
title = strings.history.searchInfoMemoTitle, // ← "Memo"
description = strings.history.searchInfoMemoDesc
)
SearchInfoItem(
title = strings.searchInfoInvoiceTitle, // ← "Invoice"
description = strings.searchInfoInvoiceDesc
title = strings.history.searchInfoInvoiceTitle, // ← "Invoice"
description = strings.history.searchInfoInvoiceDesc
)
SearchInfoItem(
title = strings.searchInfoHashTitle, // ← "Hash"
description = strings.searchInfoHashDesc
title = strings.history.searchInfoHashTitle, // ← "Hash"
description = strings.history.searchInfoHashDesc
)
SearchInfoItem(
title = strings.searchInfoPreimageTitle, // ← "Preimage"
description = strings.searchInfoPreimageDesc
title = strings.history.searchInfoPreimageTitle, // ← "Preimage"
description = strings.history.searchInfoPreimageDesc
)
SearchInfoItem(
title = strings.searchInfoNodeAliasTitle, // ← "Node alias"
description = strings.searchInfoNodeAliasDesc
title = strings.history.searchInfoNodeAliasTitle, // ← "Node alias"
description = strings.history.searchInfoNodeAliasDesc
)
SearchInfoItem(
title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
description = strings.searchInfoNodeIdDesc
title = strings.history.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
description = strings.history.searchInfoNodeIdDesc
)
Spacer(Modifier.height(16.dp))
Text(
text = strings.searchInfoFooter, // ← "You can paste a full value…"
text = strings.history.searchInfoFooter, // ← "You can paste a full value…"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -0,0 +1,111 @@
package com.bitcointxoko.gudariwallet.ui.lock
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.LockStrings
@Composable
fun BiometricLockScreen(onUnlocked: () -> Unit) {
val context = LocalContext.current
val activity = context as FragmentActivity
val strings = LocalAppStrings.current.lock
LaunchedEffect(Unit) {
showBiometricPrompt(activity, onUnlocked, strings)
}
Scaffold { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = 32.dp)
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = strings.appName,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
text = strings.authenticateToContinue,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
FilledTonalButton(
onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
Text(strings.unlockButton)
}
}
}
}
}
private fun showBiometricPrompt(
activity: FragmentActivity,
onUnlocked: () -> Unit,
strings: LockStrings
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onUnlocked()
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(strings.biometricPromptTitle)
.setSubtitle(strings.biometricPromptSubtitle)
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
prompt.authenticate(promptInfo)
}
@@ -77,13 +77,12 @@ class NdefHceService : HostApduService() {
val ins = apdu[1]
return when {
return when (ins) {
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
ins == 0xA4.toByte() -> handleSelect(apdu)
0xA4.toByte() -> handleSelect(apdu)
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
ins == 0xB0.toByte() -> handleReadBinary(apdu)
0xB0.toByte() -> handleReadBinary(apdu)
else -> SW_UNKNOWN
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
}
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
}
if (rawMessages != null && rawMessages.isNotEmpty()) {
if (!rawMessages.isNullOrEmpty()) {
val message = rawMessages[0] as NdefMessage
val uri = extractUri(message)
if (uri != null) {
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
}
if (tag != null) {
viewModelScope.launch(Dispatchers.IO) {
@@ -0,0 +1,390 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.strings
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
@Stable
private class AddConnectionState {
var description by mutableStateOf("")
var selectedPermissions by mutableStateOf(
ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
)
var neverExpires by mutableStateOf(false)
var expiry: LocalDateTime by mutableStateOf(
LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
)
var showDatePicker by mutableStateOf(false)
var showTimePicker by mutableStateOf(false)
var nextBudgetId by mutableIntStateOf(0)
var budgets by mutableStateOf(emptyList<BudgetDraft>())
// Converts the LocalDateTime expiry to a Unix epoch in seconds
fun expiryEpochSeconds(): Long =
expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
}
private fun NwcPermission.displayLabel(strings: AppStrings): String = when (serverKey) {
"pay" -> strings.nwc.permPay
"invoice" -> strings.nwc.permInvoice
"lookup" -> strings.nwc.permLookup
"history" -> strings.nwc.permHistory
"balance" -> strings.nwc.permBalance
"info" -> strings.nwc.permInfo
else -> serverKey
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (
description : String,
permissions : List<String>,
expiresAt : Long,
budgets : List<NwcNewBudget>
) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val state = remember { AddConnectionState() }
if (state.showDatePicker) {
val initialMillis = state.expiry
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialMillis
)
DatePickerDialog(
onDismissRequest = { state.showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val picked = java.time.Instant
.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
state.expiry = state.expiry
.withYear(picked.year)
.withMonth(picked.monthValue)
.withDayOfMonth(picked.dayOfMonth)
}
state.showDatePicker = false
}) { Text(strings.nwc.ok) }
},
dismissButton = {
TextButton(onClick = { state.showDatePicker = false }) { Text(strings.nwc.cancel) }
}
) { DatePicker(state = datePickerState) }
}
if (state.showTimePicker) {
val timePickerState = rememberTimePickerState(
initialHour = state.expiry.hour,
initialMinute = state.expiry.minute,
is24Hour = true
)
AlertDialog(
onDismissRequest = { state.showTimePicker = false },
confirmButton = {
TextButton(onClick = {
state.expiry = state.expiry
.withHour(timePickerState.hour)
.withMinute(timePickerState.minute)
.withSecond(0)
.withNano(0)
state.showTimePicker = false
}) { Text(strings.nwc.ok) }
},
dismissButton = {
TextButton(onClick = { state.showTimePicker = false }) { Text(strings.nwc.cancel) }
},
text = { TimePicker(state = timePickerState) }
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = strings.nwc.newConnection,
style = MaterialTheme.typography.titleMedium
)
// ── Description ──────────────────────────────────────────────────
OutlinedTextField(
value = state.description,
onValueChange = { state.description = it },
label = { Text(strings.nwc.labelHint) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ── Expiry ───────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = strings.nwc.expires, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.weight(1f))
if (!state.neverExpires) {
val dateLabel = remember(state.expiry) {
DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
onClick = { state.showDatePicker = true },
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.CalendarToday,
contentDescription = strings.nwc.pickDate,
modifier = Modifier.size(16.dp)
)
}
)
Spacer(Modifier.weight(1f))
val timeLabel = remember(state.expiry) {
DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).format(state.expiry)
}
SuggestionChip(
onClick = { state.showTimePicker = true },
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
icon = {
Icon(
imageVector = Icons.Default.Schedule,
contentDescription = strings.nwc.pickTime,
modifier = Modifier.size(16.dp)
)
}
)
}
Spacer(Modifier.weight(1f))
Checkbox(
checked = state.neverExpires,
onCheckedChange = { state.neverExpires = it }
)
Text(text = strings.nwc.never, style = MaterialTheme.typography.bodyMedium)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = strings.nwc.budgets,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.weight(1f))
TextButton(
onClick = {
state.budgets += BudgetDraft(id = state.nextBudgetId)
state.nextBudgetId++
}
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = strings.nwc.addBudget,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(strings.nwc.addBudget)
}
}
state.budgets.forEach { draft ->
key(draft.id) {
BudgetRow(
draft = draft,
onChange = { updated ->
state.budgets = state.budgets.map { if (it.id == draft.id) updated else it }
},
onRemove = {
state.budgets = state.budgets.filter { it.id != draft.id }
}
)
}
}
// ── Permissions ──────────────────────────────────────────────────
Text(
text = strings.nwc.permissions,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ALL_PERMISSIONS.forEach { perm ->
key(perm.serverKey) {
val checked = perm.serverKey in state.selectedPermissions
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { on ->
state.selectedPermissions = if (on)
state.selectedPermissions + perm.serverKey
else
state.selectedPermissions - perm.serverKey
}
)
Spacer(Modifier.width(8.dp))
Text(text = perm.displayLabel(strings), style = MaterialTheme.typography.bodyMedium)
}
}
}
val unnamed = strings.nwc.unnamed
// ── Create button ─────────────────────────────────────────────────
Button(
onClick = {
val expiresAtSecs = if (state.neverExpires) 0L
else state.expiryEpochSeconds() // #10
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
onCreate(
state.description.trim().ifBlank { unnamed },
state.selectedPermissions.toList(),
expiresAtSecs,
validBudgets
)
},
enabled = state.selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCreating) strings.nwc.creating else strings.nwc.createConnection)
}
}
}
}
// ── Budget row ────────────────────────────────────────────────────────────────
@Composable
fun BudgetRefreshWindow.displayLabel(strings: AppStrings): String = when (this) {
BudgetRefreshWindow.DAILY -> strings.nwc.refreshDaily
BudgetRefreshWindow.WEEKLY -> strings.nwc.refreshWeekly
BudgetRefreshWindow.MONTHLY -> strings.nwc.refreshMonthly
BudgetRefreshWindow.YEARLY -> strings.nwc.refreshYearly
BudgetRefreshWindow.NEVER -> strings.nwc.refreshNever
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BudgetRow(
draft : BudgetDraft,
onChange : (BudgetDraft) -> Unit,
onRemove : () -> Unit
) {
var dropdownExpanded by remember { mutableStateOf(false) }
val fieldHeight = 64.dp
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = draft.amountSats,
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
label = { Text(strings.nwc.sats) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).height(fieldHeight)
)
ExposedDropdownMenuBox(
expanded = dropdownExpanded,
onExpandedChange = { dropdownExpanded = it },
modifier = Modifier.weight(1f).height(fieldHeight)
) {
OutlinedTextField(
value = draft.refreshWindow.displayLabel(strings),
onValueChange = {},
readOnly = true,
label = { Text(strings.nwc.resets) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
},
modifier = Modifier
.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true)
.fillMaxWidth()
.height(fieldHeight)
)
ExposedDropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
BudgetRefreshWindow.entries.forEach { window ->
DropdownMenuItem(
text = { Text(window.displayLabel(strings)) },
onClick = {
onChange(draft.copy(refreshWindow = window))
dropdownExpanded = false
}
)
}
}
}
IconButton(
onClick = onRemove,
modifier = Modifier.size(fieldHeight)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = strings.nwc.removeBudget,
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@@ -0,0 +1,165 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatEpochShort
import com.bitcointxoko.gudariwallet.util.isToday
import com.bitcointxoko.gudariwallet.util.isYesterday
import kotlinx.coroutines.launch
import java.time.Instant
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
internal fun NwcConnectionRow(
connection: NwcGetResponse,
isDeleting: Boolean,
onDeleteConfirm: () -> Unit,
onConnectionClick: () -> Unit,
modifier: Modifier = Modifier
) {
val key = connection.data
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
val semantic = semanticColors()
// ── Swipe-to-delete state ────────────────────────────────────────────────
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
// Always reject — we never want the row to stay dismissed.
// The dialogue is triggered via LaunchedEffect on targetValue below.
false
},
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
)
// targetValue changes to EndToStart as soon as the drag crosses the
// threshold, even though confirmValueChange returns false and currentValue
// stays at Settled. Use a flag so we only fire once per swipe gesture.
var dialogTriggered by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Fires when the swipe crosses the threshold — trigger confirm dialogue
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
&& !isDeleting
&& !dialogTriggered
) {
dialogTriggered = true
onDeleteConfirm()
// Reset in its own scope — won't be cancelled when targetValue changes back
scope.launch { dismissState.reset() }
}
}
// Fires when the row has fully snapped back — re-arm for next swipe
LaunchedEffect(dismissState.currentValue) {
if (dismissState.currentValue == SwipeToDismissBoxValue.Settled) {
dialogTriggered = false
}
}
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = false,
enableDismissFromEndToStart = !isDeleting,
backgroundContent = {
val fraction = dismissState.progress
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
.padding(end = 24.dp),
contentAlignment = Alignment.CenterEnd
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = strings.nwc.deleteConnection,
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
)
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onConnectionClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Cable,
contentDescription = null,
tint = if (expired)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
else
MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = key.description.ifBlank { strings.nwc.unnamedConnection },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (expired)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(2.dp))
val lastUsedText = when {
key.last_used <= 0L -> strings.nwc.neverUsed
isToday(key.last_used) -> strings.nwc.lastUsedToday
isYesterday(key.last_used) -> strings.nwc.lastUsedYesterday
else -> strings.nwc.lastUsedDate.format(formatEpochShort(key.last_used, strings.today, strings.yesterday))
}
Text(
text = if (key.last_used <= 0L) strings.nwc.neverUsed
else lastUsedText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (expired) {
Spacer(Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = semantic.errorContainer,
contentColor = semantic.onErrorContainer
) {
Text(
text = strings.nwc.expired,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp)
)
}
}
}
@@ -0,0 +1,535 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.content.ClipData
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
import com.bitcointxoko.gudariwallet.util.formatEpoch
import kotlinx.coroutines.launch
// ── ConnectionDetailScreen.kt ─────────────────────────────────────────────────
//
// Displays a Nostr Wallet Connect (NWC) connection and its associated budgets.
// Modelled on PaymentDetailScreen; reuses DetailSection / DetailRow / StatusChip
// from the same UI kit.
//
// bitcoin.design guidelines applied:
// • Hero card surfaces the most important identity info (wallet + description)
// with a clear active/expired status chip — users know at a glance whether
// the connection is live.
// • Permissions are shown as individual readable rows, not a raw comma string.
// • Budget progress uses a LinearProgressIndicator so spending headroom is
// immediately scannable without arithmetic.
// • Timestamps are human-readable; raw pubkeys are truncated + copyable.
// • Destructive "Revoke" action is placed at the bottom, separated from
// informational content, and uses errorContainer colours.
// ─────────────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionDetailScreen(
connection : ConnectionData, // data { pubkey, wallet, description, … }
budgets : List<BudgetData>, // budgets [ { id, pubkey, budget_msats, … } ]
onBack : () -> Unit,
onRevoke : (() -> Unit)? = null // optional show Revoke button when provided
) {
val strings = LocalAppStrings.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.nwc.connectionDetailTitle) }, // ← "Connection"
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.nwc.back // ← "Back"
)
}
}
)
}
) { padding ->
ConnectionDetailContent(
connection = connection,
budgets = budgets,
onRevoke = onRevoke,
modifier = Modifier.padding(padding)
)
}
}
// ── Detail content ────────────────────────────────────────────────────────────
@Composable
private fun ConnectionDetailContent(
connection : ConnectionData,
budgets : List<BudgetData>,
onRevoke : (() -> Unit)?,
modifier : Modifier = Modifier
) {
val strings = LocalAppStrings.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var showRevokeDialog by remember { mutableStateOf(false) }
val nowSeconds = remember { System.currentTimeMillis() / 1_000L }
val isExpired = connection.expiresAt > 0 && connection.expiresAt < nowSeconds
val permissions = connection.permissions
if (showRevokeDialog) {
AlertDialog(
onDismissRequest = { showRevokeDialog = false },
title = { Text(strings.nwc.revokeDialogTitle) },
text = { Text(strings.nwc.revokeDialogBody) },
confirmButton = {
TextButton(
onClick = {
showRevokeDialog = false
onRevoke?.invoke()
}
) {
Text(
text = strings.nwc.revokeDialogConfirm,
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { showRevokeDialog = false }) {
Text(strings.nwc.revokeDialogDismiss)
}
}
)
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ── Hero card ─────────────────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// App / wallet icon placeholder
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(14.dp)
.fillMaxSize()
)
}
Spacer(Modifier.height(12.dp))
// Wallet name
Text(
text = connection.description.ifBlank { strings.nwc.connectionUnknownWallet }, // ← "Unknown app"
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
// Active / Expired chip
ConnectionStatusChip(isExpired = isExpired)
}
}
}
// ── Connection details ────────────────────────────────────────────────
item {
DetailSection(title = strings.nwc.connectionSectionDetails) { // ← "Details"
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Connected"
value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
)
DetailRow(
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
value = if (connection.lastUsed > 0)
formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
else
strings.nwc.connectionNeverUsed // ← "Never"
)
DetailRow(
label = strings.nwc.connectionRowExpires, // ← "Expires"
value = when {
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
formatEpoch(connection.expiresAt, strings.today, strings.yesterday)
)
else -> formatEpoch(
connection.expiresAt,
strings.today,
strings.yesterday
)
},
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
)
// Pubkey truncated display, full value copyable
DetailRow(
label = strings.nwc.connectionRowPubkey, // ← "Public key"
value = "${connection.pubkey.take(8)}${connection.pubkey.takeLast(8)}",
monospace = true,
copyable = true,
onCopy = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("pubkey", connection.pubkey))
)
}
}
)
}
}
// ── Permissions ───────────────────────────────────────────────────────
if (permissions.isNotEmpty()) {
item {
DetailSection(title = strings.nwc.connectionSectionPermissions) {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
permissions.forEachIndexed { index, permission ->
PermissionRow(permission = permission, strings)
if (index < permissions.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Budgets ───────────────────────────────────────────────────────────
if (budgets.isNotEmpty()) {
item {
Text(
text = strings.nwc.connectionSectionBudgets, // ← "Spending budgets"
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
items(budgets, key = { it.id }) { budget ->
BudgetCard(budget = budget)
}
}
// ── Revoke ────────────────────────────────────────────────────────────
if (onRevoke != null) {
item {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = { showRevokeDialog = true },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.LinkOff,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(strings.nwc.connectionRevoke) // ← "Revoke connection"
}
}
}
}
}
// ── Budget card ───────────────────────────────────────────────────────────────
@Composable
private fun BudgetCard(budget: BudgetData) {
val strings = LocalAppStrings.current
val totalSats = budget.budgetMsats / 1_000L
val usedSats = budget.usedBudgetMsats / 1_000L
val progress = if (budget.budgetMsats > 0)
(1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
else 0f
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Refresh window label e.g. "Daily budget"
Text(
text = strings.nwc.connectionBudgetTitle( // ← "Daily budget"
formatRefreshWindow(budget.refreshWindow)
),
style = MaterialTheme.typography.titleSmall
)
// Progress bar
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
// Used / Total
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = strings.nwc.connectionBudgetUsed(usedSats), // ← "Used: 1,200 sats"
style = MaterialTheme.typography.bodySmall,
color = if (isOverBudget)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = strings.nwc.connectionBudgetTotal(totalSats), // ← "of 10,000 sats"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Created at
DetailRow(
label = strings.nwc.connectionRowCreated, // ← "Created"
value = formatEpoch(budget.createdAt, strings.today, strings.yesterday)
)
}
}
}
// ── Permission row ────────────────────────────────────────────────────────────
@Composable
private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = permission.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.padding(7.dp)
.fillMaxSize()
)
}
Text(
text = permission.label(strings), // already localized
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
}
}
// ── Status chip ───────────────────────────────────────────────────────────────
@Composable
private fun ConnectionStatusChip(isExpired: Boolean) {
val strings = LocalAppStrings.current
Surface(
shape = RoundedCornerShape(50),
color = if (isExpired)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = if (isExpired) strings.nwc.connectionStatusExpired // ← "Expired"
else strings.nwc.connectionStatusActive, // ← "Active"
style = MaterialTheme.typography.labelSmall,
color = if (isExpired)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Converts a refresh_window value (seconds) to a human-readable label.
* Common NWC values: 86400 = daily, 604800 = weekly, 2592000 = monthly.
*/
private fun formatRefreshWindow(seconds: Long): String = when (seconds) {
0L -> "One-time"
86_400L -> "Daily"
604_800L -> "Weekly"
2_592_000L -> "Monthly"
else -> "${seconds / 86_400} days"
}
/**
* Maps a raw NWC method string to a friendly label.
* Extend as your permission set grows.
*/
@Composable
private fun formatPermissionLabel(permission: String, strings: AppStrings): String =
when (permission.trim().lowercase()) {
"pay_invoice" -> strings.nwc.permPayInvoice // ← "Pay invoices"
"get_balance" -> strings.nwc.permGetBalance // ← "Read balance"
"get_info" -> strings.nwc.permGetInfo // ← "Read wallet info"
"make_invoice" -> strings.nwc.permMakeInvoice // ← "Create invoices"
"lookup_invoice" -> strings.nwc.permLookupInvoice // ← "Look up invoices"
"list_transactions" -> strings.nwc.permListTransactions // ← "View transaction history"
"sign_message" -> strings.nwc.permSignMessage // ← "Sign messages"
else -> permission // fallback: raw string
}
/** Maps a permission string to a representative icon. */
private fun permissionIcon(permission: String): ImageVector =
when (permission.trim().lowercase()) {
"pay_invoice" -> Icons.Default.ArrowUpward
"get_balance" -> Icons.Default.AccountBalanceWallet
"get_info" -> Icons.Default.Info
"make_invoice" -> Icons.Default.ArrowDownward
"lookup_invoice" -> Icons.Default.Search
"list_transactions" -> Icons.AutoMirrored.Filled.List
"sign_message" -> Icons.Default.Draw
else -> Icons.Default.Key
}
// ── Data models (if not already defined elsewhere) ───────────────────────────
data class ConnectionData(
val pubkey : String,
val wallet : String,
val description : String,
val expiresAt : Long, // Unix seconds; 0 = no expiry
val permissions : List<ConnectionPermission>,
val createdAt : Long, // Unix seconds
val lastUsed : Long // Unix seconds; 0 = never
)
/**
* A resolved permission produced by matching NwcKey.permissions against
* ALL_PERMISSIONS and carrying only what the detail screen needs.
*/
data class ConnectionPermission(
val serverKey : String, // "pay", "invoice", "lookup", etc.
val icon : ImageVector
)
internal fun ConnectionPermission.label(strings: AppStrings): String =
when (serverKey) {
"pay" -> strings.nwc.permSendPayments // "Send payments"
"invoice" -> strings.nwc.permCreateInvoices // "Create invoices"
"lookup" -> strings.nwc.permLookupInvoiceStatus // "Lookup status of invoice"
"history" -> strings.nwc.permReadTransactionHistory // "Read transaction history"
"balance" -> strings.nwc.permReadWalletBalance // "Read wallet balance"
"info" -> strings.nwc.permReadAccountInfo // "Read account info"
else -> serverKey
}
data class BudgetData(
val id : Long,
val pubkey : String,
val budgetMsats : Long,
val refreshWindow : Long, // seconds
val createdAt : Long,
val usedBudgetMsats : Long
)
@@ -0,0 +1,73 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.graphics.vector.ImageVector
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcKey
import com.bitcointxoko.gudariwallet.i18n.AppStrings
/**
* Maps the API model [NwcKey] to the UI domain model [ConnectionData].
* Keeps raw API types out of the Composable layer.
*/
internal fun NwcKey.toConnectionData() = ConnectionData(
pubkey = pubkey,
wallet = wallet,
description = description,
expiresAt = expires_at,
permissions = parsePermissions(permissions),
createdAt = created_at,
lastUsed = last_used
)
/**
* Splits the raw space-separated permissions string and matches each word
* against ALL_PERMISSIONS by serverKey, preserving ALL_PERMISSIONS order.
*
* "pay invoice lookup history balance info"
* words = {"pay", "invoice", "lookup", "history", "balance", "info"}
* filter ALL_PERMISSIONS where serverKey words
* map to ConnectionPermission with localised label + icon
*/
private fun parsePermissions(raw: String): List<ConnectionPermission> {
val words = raw.trim().split("\\s+".toRegex()).toSet()
return ALL_PERMISSIONS
.filter { it.serverKey in words }
.map { perm ->
ConnectionPermission(
serverKey = perm.serverKey,
icon = permissionIcon(perm.serverKey)
)
}
}
private fun permissionIcon(serverKey: String): ImageVector =
when (serverKey) {
"pay" -> Icons.Default.ArrowUpward
"invoice" -> Icons.Default.ArrowDownward
"lookup" -> Icons.Default.Search
"history" -> Icons.Default.List
"balance" -> Icons.Default.AccountBalanceWallet
"info" -> Icons.Default.Info
else -> Icons.Default.Key
}
/**
* Maps the API model [NwcBudget] to the UI domain model [BudgetData].
* refresh_window is widened from Int to Long to match [BudgetData].
*/
internal fun NwcBudget.toBudgetData() = BudgetData(
id = id.toLong(),
pubkey = pubkey,
budgetMsats = budget_msats,
refreshWindow = refresh_window.toLong(),
createdAt = created_at,
usedBudgetMsats = used_budget_msats
)
@@ -0,0 +1,47 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
// ── Available permissions ────────────────────────────────────────────
internal data class NwcPermission(
val serverKey : String, // what we send in the permissions array
val default : Boolean // pre-checked by default
)
internal val ALL_PERMISSIONS = listOf(
NwcPermission("pay", default = true),
NwcPermission("invoice", default = false),
NwcPermission("lookup", default = false),
NwcPermission("history", default = false),
NwcPermission("balance", default = false),
NwcPermission("info", default = false)
)
// ── Budget ────────────────────────────────────────────────────────────
/**
* Transient UI state for a single budget row in the "Add connection" sheet.
* Converted to [NwcNewBudget] on submit.
*/
enum class BudgetRefreshWindow(val seconds: Int) {
DAILY (86_400),
WEEKLY (604_800),
MONTHLY (2_592_000),
YEARLY (31_536_000),
NEVER (0);
}
data class BudgetDraft(
val id : Int = 0, // stable key for LazyColumn / forEach
val amountSats : String = "", // raw text field value (sats)
val refreshWindow : BudgetRefreshWindow = BudgetRefreshWindow.MONTHLY
) {
/** Returns null when the amount field is blank or non-numeric. */
fun toNwcNewBudget(): NwcNewBudget? {
val sats = amountSats.trim().toLongOrNull()?.takeIf { it > 0 } ?: return null
return NwcNewBudget(
budget_msats = sats * 1_000L,
refresh_window = refreshWindow.seconds
)
}
}
@@ -0,0 +1,71 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.strings
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
@Composable
internal fun NwcPairingUrlDialog(
url : String,
onDismiss : () -> Unit,
onNoAppFound : () -> Unit
) {
// Brightness state lives here so the dialog owns the screen-brightness side-effect
val brightness = rememberBrightnessController()
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(strings.nwc.connectionCreated) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = strings.nwc.scanOrCopyHint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val strings = strings
QrDisplayCard(
modifier = Modifier.fillMaxWidth(),
content = url,
contentDescription = strings.nwc.nwcPairingQrCode,
clipLabel = strings.nwc.nwcConnectionString,
shareTitle = strings.nwc.shareNwcConnectionString,
textToCopy = url,
brightnessOn = brightness.isOn,
onToggleBrightness = brightness.toggle,
isNfcEmulating = null,
qrModifier = Modifier.fillMaxWidth(),
onOpenUri = {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
try {
context.startActivity(
Intent.createChooser(intent, strings.nwc.openInApp)
)
} catch (e: ActivityNotFoundException) {
onNoAppFound() // #17: no Toast — caller handles it
}
}
)
}
},
confirmButton = {}, // actions live inside QrDisplayCard
dismissButton = {
TextButton(onClick = onDismiss) { Text(strings.nwc.done) }
}
)
}
@@ -0,0 +1,175 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NwcScreen(
viewModel : NwcViewModel,
onBack : () -> Unit,
onConnectionClick : (pubkey: String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
val showAddSheet by viewModel.showAddSheet.collectAsStateWithLifecycle()
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val strings = LocalAppStrings.current
val refresh = { viewModel.loadConnections(includeExpired = true, strings) }
LaunchedEffect(Unit) {
viewModel.loadConnections(includeExpired = true, strings)
}
LaunchedEffect(pairingUrl) {
if (pairingUrl != null) {
viewModel.closeAddSheet()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(strings.nwc.walletConnect) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.nwc.back)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
Icon(Icons.Default.Add, contentDescription = strings.nwc.addConnection)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { innerPadding ->
val isRefreshing = uiState is NwcUiState.Loading ||
(uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = refresh,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (val state = uiState) {
is NwcUiState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is NwcUiState.Error -> {
NwcErrorContent(
message = state.message,
onRetry = refresh,
modifier = Modifier.fillMaxSize()
)
}
is NwcUiState.Success -> {
if (state.connections.isEmpty()) {
NwcEmptyContent(
onAddNew = { viewModel.openAddSheet() },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.connections,
key = { it.data.pubkey }
) { connection ->
NwcConnectionRow(
connection,
isDeleting,
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
{ onConnectionClick(connection.data.pubkey) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
}
}
}
}
}
// ── Add connection sheet ───────────────────────────────────────────────────
if (showAddSheet) {
NwcAddConnectionSheet(
isCreating = isCreating,
onCreate = { description, permissions, expiresAt, budgets ->
viewModel.createConnection(
description = description,
permissions = permissions,
expiresAt = expiresAt,
budgets = budgets,
strings = strings
)
},
onDismiss = { viewModel.closeAddSheet() }
)
}
// ── Pairing URL dialog ─────────────────────────────────────────────────────
pairingUrl?.let { url ->
val strings = strings
NwcPairingUrlDialog(
url = url,
onDismiss = { viewModel.clearPairingUrl() },
onNoAppFound = {
scope.launch {
snackbarHostState.showSnackbar(strings.nwc.noCompatibleAppFound)
}
}
)
}
// ── Delete confirmation dialog ─────────────────────────────────────────────
pendingDeletePubkey?.let {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text(strings.nwc.removeConnection) },
text = { Text(strings.nwc.removeConnectionDetail) },
confirmButton = {
TextButton(
onClick = { viewModel.confirmDelete(strings) }
) { Text(strings.nwc.remove, color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) { Text(strings.nwc.cancel) }
}
)
}
}
@@ -0,0 +1,68 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.strings
// ── Empty state ──────────────────────────────────────────────────────
@Composable
internal fun NwcEmptyContent(
onAddNew : () -> Unit,
modifier : Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(strings.nwc.noConnectionsYet, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
strings.nwc.tapToAddHint,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(strings.nwc.addConnection)
}
}
}
// ── Error state ──────────────────────────────────────────────────────
@Composable
internal fun NwcErrorContent(
message : String,
onRetry : () -> Unit,
modifier : Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
strings.nwc.somethingWentWrong,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(8.dp))
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onRetry) { Text(strings.nwc.retry) }
}
}

Some files were not shown because too many files have changed in this diff Show More