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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
@@ -13,40 +13,7 @@
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="getAll_baseline()"> <SelectionState runConfigName="macrobenchmark">
<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()">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z"> <DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
+23
View File
@@ -2,17 +2,40 @@
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>Aead</w> <w>Aead</w>
<w>Bech</w>
<w>Bitrefill</w>
<w>Español</w>
<w>Nbits</w> <w>Nbits</w>
<w>Nostr</w>
<w>Tink</w> <w>Tink</w>
<w>amboss</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>hkdf</w>
<w>inflight</w> <w>inflight</w>
<w>keyauth</w>
<w>lnaddr</w>
<w>lnaddress</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w> <w>lnbits</w>
<w>lnurlp</w> <w>lnurlp</w>
<w>lnurlw</w>
<w>msat</w> <w>msat</w>
<w>msats</w>
<w>nostr</w>
<w>nwccacherepository</w>
<w>satoshi</w>
<w>satoshis</w> <w>satoshis</w>
<w>sats</w> <w>sats</w>
<w>snackbar</w>
<w>snull</w>
<w>tink</w>
<w>walletconnect</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>
+1
View File
@@ -10,6 +10,7 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/macrobenchmark" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>
+55 -23
View File
@@ -1,4 +1,9 @@
import com.google.protobuf.gradle.* import com.google.protobuf.gradle.*
import java.util.Properties
val localProps = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
configurations.all { configurations.all {
resolutionStrategy { resolutionStrategy {
@@ -12,49 +17,64 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.protobuf) alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
namespace = "com.bitcointxoko.gudariwallet" namespace = "com.bitcointxoko.gudariwallet"
compileSdk { compileSdk = 37
version = release(36) { splits {
minorApiLevel = 1 abi {
isEnable = true
reset() // Clear all defaults
include("arm64-v8a") // Specify desired ABIs
isUniversalApk = false
} }
} }
defaultConfig { defaultConfig {
applicationId = "com.bitcointxoko.gudariwallet" applicationId = "com.bitcointxoko.gudariwallet"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 37
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
androidResources {
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @Suppress("UnstableApiUsage")
localeFilters += setOf("en", "es", "eu")
}
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" 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 { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true // Enables R8/ProGuard shrinking
isShrinkResources = true // Removes unused resources
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("release")
} }
create("benchmark") { create("benchmark") {
initWith(buildTypes.getByName("release")) 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 signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment
installation {
enableBaselineProfile = false // ← disables profile embedding for this build type only
}
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -67,13 +87,17 @@ android {
} }
} }
getByName("debug") { 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") { getByName("release") {
java.srcDirs("build/generated/ksp/release/kotlin") java.directories.add("build/generated/ksp/release/kotlin")
} }
getByName("benchmark") { 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 -> onVariants(selector().withBuildType("benchmark")) { variant ->
variant.packaging.resources.excludes.add("baseline-prof.txt") variant.packaging.resources.excludes.add("baseline-prof.txt")
variant.packaging.resources.excludes.add("baseline.profm") 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.material3)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.mockk)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.benchmark.junit4) androidTestImplementation(libs.androidx.benchmark.junit4)
androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -110,9 +136,9 @@ dependencies {
// Networking // Networking
implementation(libs.retrofit2.retrofit) implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson) implementation(libs.converter.kotlinx.serialization)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.logging.interceptor) debugImplementation(libs.logging.interceptor)
// Coroutines // Coroutines
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -141,7 +167,7 @@ dependencies {
implementation(libs.androidx.biometric) implementation(libs.androidx.biometric)
// JSON // JSON
implementation(libs.gson) implementation(libs.kotlinx.serialization.json)
// icons // icons
implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material.icons.extended)
@@ -151,7 +177,6 @@ dependencies {
//Navigation //Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
// animations // animations
@@ -159,6 +184,8 @@ dependencies {
// secp256k1 // secp256k1
implementation(libs.secp256k1.kmp.android) implementation(libs.secp256k1.kmp.android)
testImplementation(libs.secp256k1.kmp.jvm)
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.15.0")
// lyricist // lyricist
implementation(libs.lyricist) implementation(libs.lyricist)
@@ -168,6 +195,11 @@ dependencies {
// timber // timber
implementation(libs.timber) implementation(libs.timber)
// leakcanary
debugImplementation(libs.leakcanary.android)
testImplementation("org.json:json:20240303")
} }
protobuf { 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 # Protobuf lite
# proguardFiles setting in build.gradle. # ──────────────────────────────────────────────────────────────────
-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 # ZXing uses reflection to instantiate Reader subclasses by class name.
# http://developer.android.com/guide/developing/tools/proguard.html # 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 # Core entry points used directly via the API
# and specify the fully qualified class name to the JavaScript interface -keep class com.google.zxing.BarcodeFormat { *; }
# class: -keep class com.google.zxing.DecodeHintType { *; }
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -keep class com.google.zxing.Result { *; }
# public *; -keep class com.google.zxing.ResultMetadataType { *; }
#} -keep class com.google.zxing.MultiFormatReader { *; }
# Uncomment this to preserve the line number information for # QR code reader instantiated reflectively by MultiFormatReader
# debugging stack traces. -keep class com.google.zxing.qrcode.QRCodeReader { *; }
#-keepattributes SourceFile,LineNumberTable -keep class com.google.zxing.qrcode.decoder.** { *; }
-keep class com.google.zxing.qrcode.detector.** { *; }
# If you keep the line number information, uncomment this to # QR code generator
# hide the original source file name. -keep class com.google.zxing.qrcode.QRCodeWriter { *; }
#-renamesourcefileattribute SourceFile
# 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.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE"/> <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.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" /> <uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" 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 <application
android:name=".GudariWalletApplication" android:name=".GudariWalletApplication"
android:allowBackup="true" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() { get() {
val secrets = EncryptedSecretStore(this) val secrets = EncryptedSecretStore(this)
val api = LNbitsClient.create(secrets) val client = LNbitsClient(secrets)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
val paymentCache = PaymentCacheRepository(this) val paymentCache = PaymentCacheRepository(this)
val syncStore = HistoricalSyncStore(this) val syncStore = HistoricalSyncStore(this)
return Configuration.Builder() return Configuration.Builder()
@@ -29,7 +29,7 @@ class GudariWalletApplication : Application(), Configuration.Provider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NotificationHelper.createChannels(this) // moved from MainActivity NotificationHelper.createChannels(this)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
import android.Manifest import android.Manifest
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -37,12 +31,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings import cafe.adriel.lyricist.ProvideStrings
import com.bitcointxoko.gudariwallet.data.AppLockStore
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
import com.bitcointxoko.gudariwallet.util.NotificationConstants import com.bitcointxoko.gudariwallet.util.NotificationConstants
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var secretStore: EncryptedSecretStore private lateinit var secretStore: EncryptedSecretStore
private lateinit var appLockStore: AppLockStore
private val isUnlocked = mutableStateOf(false)
private val vm: WalletViewModel by viewModels { private val vm: WalletViewModel by viewModels {
WalletViewModelFactory(application) WalletViewModelFactory(application)
@@ -90,6 +89,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext) secretStore = EncryptedSecretStore(applicationContext)
appLockStore = AppLockStore(applicationContext)
val syncStore = HistoricalSyncStore(applicationContext) val syncStore = HistoricalSyncStore(applicationContext)
val credentialsState = secretStore.credentials val credentialsState = secretStore.credentials
.stateIn( .stateIn(
@@ -97,6 +97,12 @@ class MainActivity : AppCompatActivity() {
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = null 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 // Must be first — before any notification is posted
// NotificationHelper.createChannels(applicationContext) // NotificationHelper.createChannels(applicationContext)
@@ -127,34 +133,49 @@ class MainActivity : AppCompatActivity() {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
val credentials by credentialsState.collectAsState() val credentials by credentialsState.collectAsState()
val appLockEnabled by appLockEnabledState.collectAsState()
val isOnboarded = credentials?.isOnboarded == true val isOnboarded = credentials?.isOnboarded == true
if (isOnboarded) { when {
LaunchedEffect(Unit) { !isOnboarded -> {
WalletNotificationService.start(this@MainActivity) OnboardingScreen(
requestNotificationPermissionIfNeeded() 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 ─────────────────────────────────────────────── // ── NFC foreground dispatch ───────────────────────────────────────────────
override fun onResume() { override fun onResume() {
@@ -1,9 +1,16 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.google.gson.TypeAdapter import kotlinx.serialization.ExperimentalSerializationApi
import com.google.gson.stream.JsonReader import kotlinx.serialization.KSerializer
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonWriter 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: * LNbits returns `comment` in PaymentExtra as either:
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
* - a JSON array ["Thanks!"] * - a JSON array ["Thanks!"]
* - null * - null
* *
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter * Mirrors the behavior of the old Gson FlexibleStringAdapter.
* on the field for extra safety.
*/ */
class FlexibleStringAdapter : TypeAdapter<String?>() { object FlexibleStringSerializer : KSerializer<String?> {
override fun write(out: JsonWriter, value: String?) { override val descriptor: SerialDescriptor =
if (value == null) out.nullValue() else out.value(value) 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? { override fun deserialize(decoder: Decoder): String? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
JsonToken.BEGIN_ARRAY -> { is JsonNull -> null
val parts = mutableListOf<String>() is JsonPrimitive -> element.content // plain string → as-is
reader.beginArray() is JsonArray -> element // array → join elements
while (reader.hasNext()) { .mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
when (reader.peek()) { .joinToString(", ")
JsonToken.NULL -> reader.nextNull() .ifEmpty { null }
else -> parts.add(reader.nextString()) else -> null
}
}
reader.endArray()
parts.joinToString(", ").ifEmpty { null }
}
else -> reader.nextString()
} }
} }
} }
@@ -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 package com.bitcointxoko.gudariwallet.api
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -13,7 +15,7 @@ interface LNbitsApi {
/** GET /api/v1/wallet — returns balance and wallet info */ /** GET /api/v1/wallet — returns balance and wallet info */
@GET("api/v1/wallet") @GET("api/v1/wallet")
suspend fun getWallet( suspend fun getWallet(
@Header("X-Api-Key") apiKey: String @Header("X-Api-Key") invoiceKey: String
): WalletResponse ): WalletResponse
/** POST /api/v1/payments — creates a BOLT-11 invoice */ /** POST /api/v1/payments — creates a BOLT-11 invoice */
@@ -42,10 +44,10 @@ interface LNbitsApi {
): PayInvoiceResponse ): PayInvoiceResponse
/** GET /api/v1/payments — returns payment history */ /** GET /api/v1/payments — returns payment history */
@GET("api/v1/payments") // @GET("api/v1/payments")
suspend fun getPayments( // suspend fun getPayments(
@Header("X-Api-Key") apiKey: String // @Header("X-Api-Key") apiKey: String
): List<WsPayment> // ): List<WsPayment>
/** GET /api/v1/payments/paginated — returns bulk payment history */ /** GET /api/v1/payments/paginated — returns bulk payment history */
@GET("api/v1/payments/paginated") @GET("api/v1/payments/paginated")
@@ -144,4 +146,61 @@ interface LNbitsApi {
*/ */
@GET("api/v1/currencies") @GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String> 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 package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore 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.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference
object LNbitsClient { class LNbitsClient(secrets: SecretStore) {
// Single shared client — used by both Retrofit and WebSocket // Scope lives as long as this client — SupervisorJob so one failure
val httpClient: OkHttpClient = OkHttpClient.Builder() // doesn't cancel the URL-watching coroutine.
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // Cached in memory. Starts blank; populated as soon as DataStore emits.
.apply { private val cachedBaseUrl = AtomicReference("")
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply { init {
level = HttpLoggingInterceptor.Level.BODY 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 { val api: LNbitsApi by lazy { buildApi() }
// 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()
)
}
// Dynamic base URL interceptor — rewrites the placeholder host to the private fun buildApi(): LNbitsApi {
// 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.
val dynamicBaseUrlInterceptor = Interceptor { chain -> val dynamicBaseUrlInterceptor = Interceptor { chain ->
val realBaseUrl = runBlocking { secrets.baseUrl() } val originalRequest = chain.request()
.ifBlank { "https://placeholder.invalid" } val originalUrl = originalRequest.url
.trimEnd('/')
val realHttpUrl = realBaseUrl.toHttpUrl() 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() val newUrl = originalUrl.newBuilder()
.scheme(realHttpUrl.scheme) .scheme(realHttpUrl.scheme)
.host(realHttpUrl.host) .host(realHttpUrl.host)
.port(realHttpUrl.port) .port(realHttpUrl.port)
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
.encodedPath(
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
)
.build() .build()
chain.proceed( chain.proceed(originalRequest.newBuilder().url(newUrl).build())
chain.request().newBuilder()
.url(newUrl)
.build()
)
} }
val client = httpClient.newBuilder() val client = LNbitsHttpClient.instance.newBuilder()
.addInterceptor(authInterceptor)
.addInterceptor(dynamicBaseUrlInterceptor) .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() .build()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit; .baseUrl("https://placeholder.invalid/")
// always overwritten by the interceptor // ↑ 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) .client(client)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .addConverterFactory(
JsonProvider.json.asConverterFactory(
"application/json; charset=UTF-8".toMediaType()
)
)
.build() .build()
.create(LNbitsApi::class.java) .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 package com.bitcointxoko.gudariwallet.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.google.gson.TypeAdapter import kotlinx.serialization.KSerializer
import com.google.gson.annotations.JsonAdapter import kotlinx.serialization.SerialName
import com.google.gson.annotations.SerializedName import kotlinx.serialization.Serializable
import com.google.gson.stream.JsonReader import kotlinx.serialization.descriptors.SerialDescriptor
import com.google.gson.stream.JsonToken import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import com.google.gson.stream.JsonWriter 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 ──────────────────────────────────────────────────────────────────── // ── Wallet ────────────────────────────────────────────────────────────────────
@Serializable
data class WalletResponse( data class WalletResponse(
val id: String, val id: String = "",
val name: String, val name: String = "",
val balance: Long // millisatoshis val balance: Long = 0L // millisatoshis
) )
// ── Create invoice ──────────────────────────────────────────────────────────── // ── Create invoice ────────────────────────────────────────────────────────────
@Serializable
data class CreateInvoiceRequest( data class CreateInvoiceRequest(
val out: Boolean, val out: Boolean,
val amount: Long, // satoshis val amount: Long, // satoshis
val memo: String val memo: String
) )
@Serializable
data class CreateInvoiceResponse( data class CreateInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("payment_request") val paymentRequest: String, @SerialName("payment_request") val paymentRequest: String = "",
@SerializedName("expiry") val expiry : String? @SerialName("expiry") val expiry: String? = null
) )
// ── Pay invoice ─────────────────────────────────────────────────────────────── // ── Pay invoice ───────────────────────────────────────────────────────────────
@Serializable
data class PayInvoiceRequest( data class PayInvoiceRequest(
val out: Boolean, val out: Boolean,
val bolt11: String val bolt11: String
) )
@Serializable
data class PayInvoiceResponse( data class PayInvoiceResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String = ""
) )
// ── Check payment ───────────────────────────────────────────────────────────── // ── Check payment ─────────────────────────────────────────────────────────────
@Serializable
data class PaymentStatusResponse( data class PaymentStatusResponse(
val paid: Boolean, val paid: Boolean = false,
val details: PaymentDetails? = null val details: PaymentDetails? = null
) )
@Serializable
data class PaymentDetails( data class PaymentDetails(
val amount: Long?, val amount: Long? = null,
val fee: Long?, val fee: Long? = null,
val memo: String? val memo: String? = null
) )
// ── Decode BOLT-11 ──────────────────────────────────────────────────────────── // ── Decode BOLT-11 ────────────────────────────────────────────────────────────
@Serializable
data class DecodeInvoiceRequest( data class DecodeInvoiceRequest(
val data: String val data: String
) )
@Serializable
data class DecodeInvoiceResponse( data class DecodeInvoiceResponse(
@SerializedName("payment_hash") val paymentHash : String? = null, @SerialName("payment_hash") val paymentHash: String? = null,
@SerializedName("amount_msat") val amountMsat : Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
@SerializedName("description") val description : String? = null, @SerialName("description") val description: String? = null,
@SerializedName("payee") val payee : String? = null, @SerialName("payee") val payee: String? = null,
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null @SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
) )
@Serializable
data class RouteHintHop( data class RouteHintHop(
@SerializedName("public_key") val publicKey : String, @SerialName("public_key") val publicKey: String = "",
@SerializedName("short_channel_id") val shortChannelId : String? = null, @SerialName("short_channel_id") val shortChannelId: String? = null,
@SerializedName("base_fee") val baseFeeMsat : Long = 0, @SerialName("base_fee") val baseFeeMsat: Long = 0,
@SerializedName("ppm_fee") val ppmFee : Long = 0, @SerialName("ppm_fee") val ppmFee: Long = 0,
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0 @SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
) )
// ── LNURL scan ──────────────────────────────────────────────────────────────── // ── LNURL scan ────────────────────────────────────────────────────────────────
@Serializable
data class LnurlScanResponse( data class LnurlScanResponse(
val tag: String?, val tag: String? = null,
// ── payRequest fields ──────────────────────────────────────────────────── // ── payRequest fields ────────────────────────────────────────────────────
val callback: String? = null, val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long? = null, @SerialName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long? = null, @SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String? = null, val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int? = null, @SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null, @SerialName("nostrPubkey") val nostrPubkey: String? = null,
val bolt11: String? = null, val bolt11: String? = null,
@SerializedName("amount_msat") val amountMsat: Long? = null, @SerialName("amount_msat") val amountMsat: Long? = null,
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache) val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
// ── withdrawRequest fields ─────────────────────────────────────────────── // ── withdrawRequest fields ───────────────────────────────────────────────
val k1 : String? = null, val k1: String? = null,
@SerializedName("defaultDescription") @SerialName("defaultDescription") val defaultDescription: String? = null,
val defaultDescription: String? = null, @SerialName("minWithdrawable") val minWithdrawable: Long? = null,
@SerializedName("minWithdrawable") @SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
val minWithdrawable : Long? = null, @SerialName("balanceCheck") val balanceCheck: String? = null,
@SerializedName("maxWithdrawable") @SerialName("currentBalance") val currentBalance: Long? = null,
val maxWithdrawable : Long? = null, // ── Error fields ──────────────────────────────────────────────────────────
@SerializedName("balanceCheck") val status: String? = null, // "OK" or "ERROR"
val balanceCheck : String? = null, val reason: String? = null // error message when status == "ERROR"
@SerializedName("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) ───────────────────────────────────── // ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
@Serializable
data class LnurlCallbackResponse( data class LnurlCallbackResponse(
val pr : String, // BOLT-11 invoice to pay val pr: String = "",
val routes : List<Any>? = null, val routes: List<JsonElement>? = null,
@SerializedName("successAction") @SerialName("successAction") val successAction: SuccessAction? = null,
val successAction : SuccessAction? = null, val status: String? = null,
// Error fields (server may return status=ERROR instead of pr) val reason: String? = null
val status : String? = null,
val reason : String? = null
) )
// ── LNURL pay ───────────────────────────────────────────────────────────────── // ── LNURL pay ─────────────────────────────────────────────────────────────────
@Serializable
data class LnurlPayRes( data class LnurlPayRes(
val tag: String?, val tag: String? = null,
val callback: String?, val callback: String? = null,
@SerializedName("minSendable") val minSendable: Long?, @SerialName("minSendable") val minSendable: Long? = null,
@SerializedName("maxSendable") val maxSendable: Long?, @SerialName("maxSendable") val maxSendable: Long? = null,
val metadata: String?, val metadata: String? = null,
@SerializedName("commentAllowed") val commentAllowed: Int?, @SerialName("commentAllowed") val commentAllowed: Int? = null,
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null, @SerialName("allowsNostr") val allowsNostr: Boolean? = null,
@SerializedName("nostrPubkey") val nostrPubkey: String? = null @SerialName("nostrPubkey") val nostrPubkey: String? = null
) )
@Serializable
data class PayLnurlRequest( data class PayLnurlRequest(
val res: LnurlPayRes, val res: LnurlPayRes,
val lnurl: String, val lnurl: String,
@@ -135,45 +149,48 @@ data class PayLnurlRequest(
val comment: String? = null val comment: String? = null
) )
@Serializable
data class PayLnurlResponse( data class PayLnurlResponse(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String @SerialName("checking_id") val checkingId: String = ""
) )
@Serializable
data class WithdrawCallbackResponse( data class WithdrawCallbackResponse(
val status : String, val status: String = "",
val reason : String? = null val reason: String? = null
) )
// ── LNURLp pay link (lightning address) ────────────────────────────────────── // ── LNURLp pay link (lightning address) ──────────────────────────────────────
@Serializable
data class LnurlpLink( data class LnurlpLink(
val id : String, val id: String = "",
val wallet : String, val wallet: String = "",
val username : String?, // the part before the @ in the lightning address val username: String? = null,
val description : String?, val description: String? = null,
val min : Long, @Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
val max : Long @Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
) )
// ── Payment history ─────────────────────────────────────────────────────────── // ── Payment history ───────────────────────────────────────────────────────────
@Immutable @Immutable
@Serializable
data class PaymentRecord( data class PaymentRecord(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String = "",
@SerializedName("amount") val amountMsat: Long, @SerialName("amount") val amountMsat: Long = 0L,
@SerializedName("fee") val feeMsat: Long, @SerialName("fee") val feeMsat: Long = 0L,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String? = null,
@SerializedName("time") val time: String, @SerialName("time") val time: String = "",
@SerializedName("status") val status: String, @SerialName("status") val status: String = "",
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String? = null,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean = false,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String? = null,
@SerializedName("extra") val extra: PaymentExtra?, @SerialName("extra") val extra: PaymentExtra? = null,
// Not from the API — populated only when loaded from Room via toDomain() // Not from the API — populated only when loaded from Room via toDomain()
val createdAt: Long? = null, val createdAt: Long? = null,
val pubkey: String? = null, val pubkey: String? = null,
val alias: String? = null val alias: String? = null
) { ) {
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
@@ -181,13 +198,13 @@ data class PaymentRecord(
companion object { companion object {
fun fromPayment( fun fromPayment(
paymentHash : String, paymentHash: String,
amountSats : Long, amountSats: Long,
feeSats : Long?, feeSats: Long?,
bolt11 : String?, bolt11: String?,
memo : String?, memo: String?,
pubkey : String?, pubkey: String?,
alias : String?, alias: String?,
): PaymentRecord = PaymentRecord( ): PaymentRecord = PaymentRecord(
paymentHash = paymentHash, paymentHash = paymentHash,
checkingId = paymentHash, checkingId = paymentHash,
@@ -207,23 +224,25 @@ data class PaymentRecord(
} }
@Immutable @Immutable
@Serializable
data class PaymentExtra( data class PaymentExtra(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String? = null,
@JsonAdapter(FlexibleStringAdapter::class) @Serializable(with = FlexibleStringSerializer::class)
val comment: String?, val comment: String? = null,
@SerializedName("success_action") val successAction: SuccessAction?, @SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
@SerializedName("wallet_id") val walletId: String?, val successAction: SuccessAction? = null,
@SerializedName("destination") val destination: String?, @SerialName("wallet_id") val walletId: String? = null,
@SerializedName("expires_at") val expiresAt: String? @SerialName("destination") val destination: String? = null,
@SerialName("expires_at") val expiresAt: String? = null
) )
@Immutable @Immutable
@Serializable(with = SuccessActionSerializer::class)
data class SuccessAction( data class SuccessAction(
@SerializedName("tag") val tag: String?, @SerialName("tag") val tag: String?,
@SerializedName("message") val message: String?, @SerialName("message") val message: String?,
@SerializedName("url") val url: String?, @SerialName("url") val url: String?,
@SerializedName("description") val description: String? @SerialName("description") val description: String?
) )
/** /**
@@ -232,93 +251,158 @@ data class SuccessAction(
* - a plain string "Thanks!" * - a plain string "Thanks!"
* - null * - null
* *
* Gson's default deserialiser throws IllegalStateException when it sees a * kotlinx.serialization's default deserializer throws when it sees a
* string where it expects {. This adapter handles all three cases. * string where it expects {. This serializer handles all three cases.
* *
* Registered centrally in [GsonProvider] — applies to all deserialization * Applied via @Serializable(with = SuccessActionSerializer::class) on
* paths automatically. * the SuccessAction class and on PaymentExtra.successAction.
*/ */
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() { object SuccessActionSerializer : KSerializer<SuccessAction?> {
override fun write(out: JsonWriter, value: SuccessAction?) { override val descriptor: SerialDescriptor =
if (value == null) { out.nullValue(); return } buildClassSerialDescriptor("SuccessAction")
out.beginObject()
out.name("tag"); out.value(value.tag) override fun serialize(encoder: Encoder, value: SuccessAction?) {
out.name("message"); out.value(value.message) val jsonEncoder = encoder as JsonEncoder
out.name("url"); out.value(value.url) if (value == null) {
out.name("description"); out.value(value.description) jsonEncoder.encodeJsonElement(JsonNull)
out.endObject() } 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? { override fun deserialize(decoder: Decoder): SuccessAction? {
return when (reader.peek()) { val jsonDecoder = decoder as JsonDecoder
JsonToken.NULL -> { reader.nextNull(); null } return when (val element = jsonDecoder.decodeJsonElement()) {
is JsonNull -> null
// Plain string — treat as a message-type success action is JsonPrimitive -> {
JsonToken.STRING -> { // Plain string — treat as a message-type success action
val text = reader.nextString() val text = element.jsonPrimitive.content
if (text.isBlank()) null if (text.isBlank()) null
else SuccessAction(tag = "message", message = text, url = null, description = null) else SuccessAction(tag = "message", message = text, url = null, description = null)
} }
is JsonObject -> {
// Normal object SuccessAction(
JsonToken.BEGIN_OBJECT -> { tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
reader.beginObject() message = element.jsonObject["message"]?.jsonPrimitive?.content,
var tag: String? = null url = element.jsonObject["url"]?.jsonPrimitive?.content,
var message: String? = null description = element.jsonObject["description"]?.jsonPrimitive?.content
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)
} }
else -> null
else -> { reader.skipValue(); null }
} }
} }
} }
@Serializable
data class PaymentDetailResponse( data class PaymentDetailResponse(
val paid: Boolean, val paid: Boolean,
val details: PaymentRecord? val details: PaymentRecord?
) )
// ── WebSocket payment message ───────────────────────────────────────────────── // ── WebSocket payment message ─────────────────────────────────────────────────
@Serializable
data class WsPaymentMessage( data class WsPaymentMessage(
@SerializedName("wallet_balance") val walletBalance: Long?, @SerialName("wallet_balance") val walletBalance: Long? = null,
val payment: WsPayment? val payment: WsPayment? = null
) )
@Serializable
data class WsPayment( data class WsPayment(
@SerializedName("payment_hash") val paymentHash: String, @SerialName("payment_hash") val paymentHash: String = "",
@SerializedName("checking_id") val checkingId: String, @SerialName("checking_id") val checkingId: String = "",
@SerializedName("amount") val amount: Long, @SerialName("amount") val amount: Long = 0L,
@SerializedName("fee") val fee: Long, @SerialName("fee") val fee: Long = 0L,
@SerializedName("memo") val memo: String?, @SerialName("memo") val memo: String? = null,
@SerializedName("time") val time: String, @SerialName("time") val time: String = "",
@SerializedName("status") val status: String, @SerialName("status") val status: String = "",
@SerializedName("bolt11") val bolt11: String?, @SerialName("bolt11") val bolt11: String? = null,
@SerializedName("pending") val pending: Boolean, @SerialName("pending") val pending: Boolean = false,
@SerializedName("preimage") val preimage: String?, @SerialName("preimage") val preimage: String? = null,
@SerializedName("extra") val extra: PaymentExtra? @SerialName("extra") val extra: PaymentExtra? = null
) )
// ── Fiat rate ─────────────────────────────────────────────────────────────────
// ── Fiat rate ──────────────────────────────────────────────────────────────── @Serializable
data class FiatRateResponse( data class FiatRateResponse(
val rate: Double, // sat to fiat rate val rate: Double = 0.0, // sat to fiat rate
val price : Double // BTC price in the requested fiat currency val price: Double = 0.0 // BTC price in the requested fiat currency
) )
// ── History sync ───────────────────────────────────────────────────────────── // ── History sync ─────────────────────────────────────────────────────────────
@Serializable
data class PaginatedPaymentsResponse( data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>, @SerialName("data") val data: List<PaymentRecord> = emptyList(),
@SerializedName("total") val total: Int @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 package com.bitcointxoko.gudariwallet.api
import kotlinx.serialization.Serializable
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
// ── mempool.space ───────────────────────────────────────────────────────────── // ── mempool.space ─────────────────────────────────────────────────────────────
@Serializable
data class MempoolNodeResponse( data class MempoolNodeResponse(
val alias: String?, val alias: String?,
val public_key: String? val public_key: String?
@@ -17,6 +19,7 @@ interface MempoolApi {
// ── 1ml.com ─────────────────────────────────────────────────────────────────── // ── 1ml.com ───────────────────────────────────────────────────────────────────
@Serializable
data class OneMlNodeResponse( data class OneMlNodeResponse(
val alias: String?, val alias: String?,
val pub_key: String? val pub_key: String?
@@ -1,8 +1,9 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
@@ -17,17 +18,20 @@ class NodeAliasClient {
.readTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS)
.build() .build()
private val converter = JsonProvider.json
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
private val mempoolApi: MempoolApi = Retrofit.Builder() private val mempoolApi: MempoolApi = Retrofit.Builder()
.baseUrl("https://mempool.space/") .baseUrl("https://mempool.space/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(MempoolApi::class.java) .create(MempoolApi::class.java)
private val oneMlApi: OneMlApi = Retrofit.Builder() private val oneMlApi: OneMlApi = Retrofit.Builder()
.baseUrl("https://1ml.com/") .baseUrl("https://1ml.com/")
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(converter)
.build() .build()
.create(OneMlApi::class.java) .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.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import java.io.IOException import java.io.IOException
private const val TAG = "FiatDataStore" private const val TAG = "FiatDataStore"
@@ -27,13 +28,13 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
class FiatDataStore(context: Context) { class FiatDataStore(context: Context) {
private val dataStore = context.applicationContext.fiatDataStore private val dataStore = context.applicationContext.fiatDataStore
private val gson = Gson() private val json = JsonProvider.json
// ── Key factories ───────────────────────────────────────────────────────── // ── 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 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 ────────────────────────────────────────────────────────────────── // ── Rate ──────────────────────────────────────────────────────────────────
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
} else throw e } else throw e
} }
.first() .first()
val json = prefs[currenciesKey] ?: return null val jsonString = prefs[currenciesKey] ?: return null
return runCatching { return runCatching {
val type = object : TypeToken<List<String>>() {}.type val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
val list: List<String> = gson.fromJson(json, type)
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)") Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
list list
}.getOrElse { e -> }.getOrElse { e ->
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
if (currencies.isEmpty()) return if (currencies.isEmpty()) return
runCatching { runCatching {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[currenciesKey] = gson.toJson(currencies) prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
} }
}.onFailure { e -> }.onFailure { e ->
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}") Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
@@ -1,5 +1,8 @@
package com.bitcointxoko.gudariwallet.data package com.bitcointxoko.gudariwallet.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber import timber.log.Timber
private const val TAG = "FiatRateCache" private const val TAG = "FiatRateCache"
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
*/ */
class FiatRateCache(private val fiatDataStore: FiatDataStore) { class FiatRateCache(private val fiatDataStore: FiatDataStore) {
private val _fiatFlow = MutableStateFlow<Pair<String?, Double?>>(Pair(null, null))
/** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */
val fiatFlow: StateFlow<Pair<String?, Double?>> = _fiatFlow.asStateFlow()
// ── Rate ────────────────────────────────────────────────────────────────── // ── Rate ──────────────────────────────────────────────────────────────────
private var cachedRate : Double? = null private var cachedRate : Double? = null
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = rate cachedRate = rate
rateLastFetchedAt = now rateLastFetchedAt = now
cachedRateCurrency = currency cachedRateCurrency = currency
_fiatFlow.value = Pair(currency, rate)
fiatDataStore.persistRate(currency, rate) fiatDataStore.persistRate(currency, rate)
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted") Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
} }
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
cachedRate = null cachedRate = null
rateLastFetchedAt = 0L rateLastFetchedAt = 0L
cachedRateCurrency = "" cachedRateCurrency = ""
_fiatFlow.value = Pair(null, null)
} }
// ── Currency list ───────────────────────────────────────────────────────── // ── Currency list ─────────────────────────────────────────────────────────
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies") Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
} }
val currentRate: Double? val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
} }
@@ -286,4 +286,131 @@ class LNbitsWalletRepository(
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId) 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 -> .map { prefs ->
prefs[KEY_ADDRESS].also { address -> prefs[KEY_ADDRESS].also { address ->
_currentAddress = address _currentAddress = address
Timber.d("LNADDR [CACHE ] read from cache: " + Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
} }
suspend fun isCacheStale(): Boolean { suspend fun isCacheStale(): Boolean {
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
val age = System.currentTimeMillis() - fetchedAt val age = System.currentTimeMillis() - fetchedAt
val isStale = address == null || age > LNADDRESS_TTL_MS val isStale = address == null || age > LNADDRESS_TTL_MS
Timber.d("LNADDR [TTL ] cached=${address != null} " + Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " + "age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
if (isStale) "→ cache stale, will fetch from API"
else "→ cache fresh, skipping API call") else "→ cache fresh, skipping API call")
isStale isStale
} }
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
import android.content.Context import android.content.Context
import timber.log.Timber 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.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.data.db.AppDatabase import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
class LnurlCacheRepository(context: Context) { class LnurlCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).lnurlCacheDao() 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 // Fast in-memory path — avoids a DB round-trip for keys already loaded
// this session. Mirrors the pattern used in PaymentCacheRepository. // this session. Mirrors the pattern used in PaymentCacheRepository.
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
return null return null
} }
val response = deserialise(entity.responseJson) ?: run { val response = deserialize(entity.responseJson) ?: run {
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry") Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
evict(key) evict(key)
return null return null
@@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) {
*/ */
suspend fun put(key: String, response: LnurlScanResponse) { suspend fun put(key: String, response: LnurlScanResponse) {
val disposable = response.disposable val disposable = response.disposable
if ( disposable == true) { if (disposable == true) {
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching") Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
return return
} }
val entity = LnurlCacheEntity( val entity = LnurlCacheEntity(
key = key, key = key,
responseJson = gson.toJson(response), responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
savedAt = System.currentTimeMillis() savedAt = System.currentTimeMillis()
) )
runCatching { runCatching {
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
runCatching { dao.deleteByKey(key) } runCatching { dao.deleteByKey(key) }
} }
private fun deserialise(json: String): LnurlScanResponse? = private fun deserialize(jsonString: String): LnurlScanResponse? =
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) } runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
.getOrNull() .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.util.WalletConstants
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
import kotlinx.coroutines.flow.Flow
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentCacheRepository" private const val TAG = "PaymentCacheRepository"
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
if (payments.isEmpty()) return if (payments.isEmpty()) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
runCatching { runCatching {
dao.insertAll(payments.map { it.toEntity(now) }) payments.forEach { payment ->
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})") val e = payment.toEntity(now)
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
}
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
}.onFailure { }.onFailure {
Timber.w("PAYMENTS [MERGE ERR] ${it.message}") Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
} }
} }
/** Persist the first page of payments to the database. */ /** Persist the first page of payments to the database. */
suspend fun savePayments(payments: List<PaymentRecord>) { suspend fun savePayments(payments: List<PaymentRecord>) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@@ -109,12 +132,14 @@ class PaymentCacheRepository(context: Context) {
minCreatedAt = filter.minCreatedAt, minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt maxCreatedAt = filter.maxCreatedAt
).map { it.toDomain() }.also { ).map { it.toDomain() }.also {
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " + Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
"amt=${filter.minAmountSat}${filter.maxAmountSat} " + "amt=${filter.minAmountSat}${filter.maxAmountSat} ")
"date=${filter.minCreatedAt}${filter.maxCreatedAt}${it.size} rows")
} }
} }
/** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */
fun observeAll(): Flow<List<PaymentRecordEntity>> = dao.observeAll()
// ── Payment detail ──────────────────────────────────────────────────────── // ── Payment detail ────────────────────────────────────────────────────────
/** Returns a cached detail response, or null if not yet fetched this session. */ /** Returns a cached detail response, or null if not yet fetched this session. */
@@ -131,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
/** Upsert PaymentRecord to database. */ /** Upsert PaymentRecord to database. */
suspend fun upsertPayment(payment: PaymentRecord) { suspend fun upsertPayment(payment: PaymentRecord) {
val entity = payment.toEntity(savedAt = System.currentTimeMillis()) val e = payment.toEntity(savedAt = System.currentTimeMillis())
dao.insert(entity) Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
dao.updateSafe( // always patches server-owned fields in-place
checkingId = e.checkingId,
paymentHash = e.paymentHash,
amountMsat = e.amountMsat,
feeMsat = e.feeMsat,
memo = e.memo,
time = e.time,
createdAt = e.createdAt,
status = e.status,
bolt11 = e.bolt11,
pending = e.pending,
preimage = e.preimage,
extra = e.extra,
savedAt = e.savedAt
)
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}") Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
} }
@@ -160,4 +201,34 @@ class PaymentCacheRepository(context: Context) {
dao.deleteAll() dao.deleteAll()
Timber.d("PAYMENTS [CLEARED ] cache wiped") 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 package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse 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.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
@@ -41,4 +43,20 @@ interface WalletRepository {
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String> 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, PaymentRecordEntity::class,
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, NodeAliasCacheEntity::class,
NwcKeyEntity::class,
NwcBudgetEntity::class,
ContactEntity::class,
PaymentAddressEntity::class,
TxContactCrossRef::class,
], ],
version = 6, version = 10,
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -23,6 +28,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
abstract fun nwcKeyDao(): NwcKeyDao
abstract fun contactDao(): ContactDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @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 = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -93,7 +226,17 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "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() .build()
.also { INSTANCE = it } .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 package com.bitcointxoko.gudariwallet.data.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.PaymentExtra import com.bitcointxoko.gudariwallet.api.PaymentExtra
import com.bitcointxoko.gudariwallet.api.GsonProvider
object PaymentConverters { object PaymentConverters {
private val gson = GsonProvider.gson private val json = JsonProvider.json
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun fromExtra(extra: PaymentExtra?): String? = fun fromExtra(extra: PaymentExtra?): String? =
extra?.let { gson.toJson(it) } extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
@TypeConverter @TypeConverter
@JvmStatic @JvmStatic
fun toExtra(json: String?): PaymentExtra? = fun toExtra(string: String?): PaymentExtra? =
json?.let { gson.fromJson(it, PaymentExtra::class.java) } string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
} }
@@ -4,6 +4,8 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface PaymentDao { interface PaymentDao {
@@ -13,10 +15,53 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(payment: PaymentRecordEntity) suspend fun insert(payment: PaymentRecordEntity)
// Safe upsert used by mergePayments only.
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
// the server-owned fields in-place — the parent row is never deleted,
// so the TxContactCrossRef CASCADE is never triggered.
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertIgnore(payment: PaymentRecordEntity)
@Query("""
UPDATE payment_records SET
paymentHash = :paymentHash,
amountMsat = :amountMsat,
feeMsat = :feeMsat,
memo = :memo,
time = :time,
createdAt = :createdAt,
status = :status,
bolt11 = :bolt11,
pending = :pending,
preimage = :preimage,
extra = :extra,
savedAt = :savedAt
WHERE checkingId = :checkingId
""")
suspend fun updateSafe(
checkingId: String,
paymentHash: String,
amountMsat: Long,
feeMsat: Long,
memo: String?,
time: String,
createdAt: Long?,
status: String,
bolt11: String?,
pending: Boolean,
preimage: String?,
extra: String?,
savedAt: Long
)
/** Returns all rows ordered by time descending. */ /** Returns all rows ordered by time descending. */
@Query("SELECT * FROM payment_records ORDER BY time DESC") @Query("SELECT * FROM payment_records ORDER BY time DESC")
suspend fun getAll(): List<PaymentRecordEntity> suspend fun getAll(): List<PaymentRecordEntity>
/** Reactive version of [getAll] — emits a new list whenever any row changes. */
@Query("SELECT * FROM payment_records ORDER BY COALESCE(createdAt, CAST(time AS INTEGER)) DESC")
fun observeAll(): Flow<List<PaymentRecordEntity>>
/** A single page, ordered newest-first. */ /** A single page, ordered newest-first. */
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity> suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
@@ -32,6 +77,30 @@ interface PaymentDao {
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1") @Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity? suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
@Query("""
SELECT * FROM payment_records
WHERE paymentHash IN (:paymentHashes)
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
AND (:statusPattern IS NULL OR
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
ORDER BY createdAt DESC, time DESC
""")
suspend fun getByPaymentHashesFiltered(
paymentHashes : List<String>,
minAmountMsat : Long? = null,
maxAmountMsat : Long? = null,
minCreatedAt : Long? = null,
maxCreatedAt : Long? = null,
statusPattern : String? = null
): List<PaymentRecordEntity>
@Query("DELETE FROM payment_records") @Query("DELETE FROM payment_records")
suspend fun deleteAll() suspend fun deleteAll()
@@ -1,12 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import timber.log.Timber 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.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentMappers" private const val TAG = "PaymentMappers"
private val json = JsonProvider.json
// ── Parse helper — runs once at write time, never again ────────────────────── // ── Parse helper — runs once at write time, never again ──────────────────────
@@ -36,10 +36,10 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.toJson(it) }, extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
savedAt = savedAt, savedAt = savedAt,
pubkey = pubkey, pubkey = pubkey,
alias = alias alias = alias
) )
// ── PaymentRecordEntity → PaymentRecord ────────────────────────────────────── // ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
preimage = preimage, preimage = preimage,
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) }, extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
pubkey = pubkey, pubkey = pubkey,
alias = alias 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 package com.bitcointxoko.gudariwallet.i18n
import cafe.adriel.lyricist.LyricistStrings import cafe.adriel.lyricist.LyricistStrings
import kotlin.String
@LyricistStrings(languageTag = "en", default = true) @LyricistStrings(languageTag = "en", default = true)
val EnHomeStrings = AppStrings( val EnHomeStrings = AppStrings(
@@ -55,61 +54,8 @@ val EnHomeStrings = AppStrings(
details = "Details", details = "Details",
enterValidAmount = "Enter a valid amount", enterValidAmount = "Enter a valid amount",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "today",
// ── ReceiveScreen — general ─────────────────────────────────────────────── yesterday = "yesterday",
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",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Send", send = "Send",
@@ -196,118 +142,6 @@ val EnHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" }, sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address", 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( onboarding = OnboardingStrings(
back = "Back", back = "Back",
next = "Next", next = "Next",
@@ -325,6 +159,7 @@ val EnHomeStrings = AppStrings(
hideAdminKey = "Hide", hideAdminKey = "Hide",
showAdminKey = "Show", showAdminKey = "Show",
fieldsRequired = "All fields are required.", fieldsRequired = "All fields are required.",
invalidKeyFormat = "Invalid key format",
connect = "Connect", connect = "Connect",
notificationsTitle = "Stay in the loop", notificationsTitle = "Stay in the loop",
notificationsSubtitle = "Get notified instantly when you receive a payment.", notificationsSubtitle = "Get notified instantly when you receive a payment.",
@@ -352,6 +187,301 @@ val EnHomeStrings = AppStrings(
scheduleSync = "Schedule sync", scheduleSync = "Schedule sync",
schedule = "Schedule", schedule = "Schedule",
cancel = "Cancel" 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", details = "Detalles",
enterValidAmount = "Introduce una cantidad válida", enterValidAmount = "Introduce una cantidad válida",
satsUnit = { sats -> "$sats sats" }, satsUnit = { sats -> "$sats sats" },
today = "hoy",
// ── ReceiveScreen — general ─────────────────────────────────────────────── yesterday = "ayer",
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",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar", send = "Enviar",
@@ -195,119 +142,6 @@ val EsHomeStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" }, sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección", 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( onboarding = OnboardingStrings(
back = "Atrás", back = "Atrás",
next = "Siguiente", next = "Siguiente",
@@ -325,6 +159,7 @@ val EsHomeStrings = AppStrings(
hideAdminKey = "Ocultar", hideAdminKey = "Ocultar",
showAdminKey = "Mostrar", showAdminKey = "Mostrar",
fieldsRequired = "Todos los campos son obligatorios.", fieldsRequired = "Todos los campos son obligatorios.",
invalidKeyFormat = "Formato de clave no válido",
connect = "Conectar", connect = "Conectar",
notificationsTitle = "Mantente al día", notificationsTitle = "Mantente al día",
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.", notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
@@ -352,5 +187,300 @@ val EsHomeStrings = AppStrings(
scheduleSync = "Programar sincronización", scheduleSync = "Programar sincronización",
schedule = "Programar", schedule = "Programar",
cancel = "Cancelar" 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", details = "Xehetasunak",
enterValidAmount = "Sartu zenbateko baliogarri bat", enterValidAmount = "Sartu zenbateko baliogarri bat",
satsUnit = { sats -> "$sats sat" }, satsUnit = { sats -> "$sats sat" },
today = "gaur",
// ── ReceiveScreen — general ─────────────────────────────────────────────── yesterday = "atzo",
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",
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
send = "Bidali", send = "Bidali",
@@ -196,118 +143,6 @@ val EuStrings = AppStrings(
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" }, sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu", 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( onboarding = OnboardingStrings(
back = "Atzera", back = "Atzera",
next = "Hurrengoa", next = "Hurrengoa",
@@ -325,6 +160,7 @@ val EuStrings = AppStrings(
hideAdminKey = "Ezkutatu", hideAdminKey = "Ezkutatu",
showAdminKey = "Erakutsi", showAdminKey = "Erakutsi",
fieldsRequired = "Eremu guztiak beharrezkoak dira.", fieldsRequired = "Eremu guztiak beharrezkoak dira.",
invalidKeyFormat = "Gako formatu baliogabea",
connect = "Konektatu", connect = "Konektatu",
notificationsTitle = "Egon eguneratuta", notificationsTitle = "Egon eguneratuta",
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.", notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
@@ -352,5 +188,299 @@ val EuStrings = AppStrings(
scheduleSync = "Programatu sinkronizazioa", scheduleSync = "Programatu sinkronizazioa",
schedule = "Programatu", schedule = "Programatu",
cancel = "Utzi" 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 details : String,
val enterValidAmount : String, val enterValidAmount : String,
val satsUnit : (sats: Long) -> String, val satsUnit : (sats: Long) -> String,
val today : String,
// ── ReceiveScreen — general ─────────────────────────────────────────────── val yesterday : String,
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,
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
val send : String, val send : String,
@@ -204,6 +151,120 @@ data class AppStrings(
val sendVmUnsupportedType : (tag: String) -> String, val sendVmUnsupportedType : (tag: String) -> String,
val sendVmPaymentFailedRescan : 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 ───────────────────────────────────────────────────────── // ── HistoryScreen ─────────────────────────────────────────────────────────
val historyTitle : String, val historyTitle : String,
val historyClose : String, val historyClose : String,
@@ -303,52 +364,133 @@ data class AppStrings(
val paymentRowNoMemo : String, val paymentRowNoMemo : String,
val paymentRowPending : String, val paymentRowPending : String,
val paymentRowFailed : String, val paymentRowFailed : String,
val onboarding: OnboardingStrings
) )
data class OnboardingStrings( data class NwcStrings(
val title: String,
val subtitle: String, // ── Shared / navigation ───────────────────────────────────────────────────
val serverUrl: String, val back : String,
val serverUrlHint: String, val next : String,
val invoiceKey: String, val done : String,
val invoiceKeySubtitle: String, val cancel : String,
val pasteInvoiceKey: String, val schedule : String,
val adminKey: String,
val adminKeySubtitle: String, // ── Connection detail ─────────────────────────────────────────────────────
val pasteAdminKey: String, val connectionDetailTitle : String,
val showAdminKey: String, val connectionUnknownWallet : String,
val hideAdminKey: String, val connectionSectionDetails : String,
val connect: String, val connectionRowCreated : String,
val fieldsRequired: String, val connectionRowLastUsed : String,
val back: String, val connectionRowExpires : String,
val next: String, val connectionRowPubkey : String,
val done: String, val connectionNeverUsed : String,
val notificationsTitle: String, val lastUsedToday : String, // e.g. "Last used today"
val notificationsSubtitle: String, val lastUsedYesterday : String, // e.g. "Last used yesterday"
val notificationsAlreadyGranted: String, val lastUsedDate : String, // e.g. "Last used %s" — %s is a formatted date
val allowNotifications: String, val connectionNoExpiry : String,
val notificationsNote: String, /** "Expired · {date}" */
val notificationsAutoGranted: String, val connectionExpired : (date: String) -> String,
val batteryTitle: String, val connectionSectionPermissions : String,
val batterySubtitle: String, val connectionSectionBudgets : String,
val openSettings: String, /** "{window} budget" e.g. "Daily budget" */
val batteryNote: String, val connectionBudgetTitle : (window: String) -> String,
val batteryAlreadyUnrestricted: String, /** "Used: {sats} sats" */
val paymentHistory: String, val connectionBudgetUsed : (sats: Long) -> String,
val paymentHistorySubtitle: String, /** "of {sats} sats" */
val syncNow: String, val connectionBudgetTotal : (sats: Long) -> String,
val skipForNow: String, val connectionRevoke : String,
val syncScheduled: String, val connectionStatusActive : String,
val continueToApp: String, val connectionStatusExpired : String,
val connecting: String,
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String, // ── NWC permission labels ─────────────────────────────────────────────────
val paymentsSynced: (total: Int) -> String, val permPayInvoice : String,
val syncComplete: String, val permGetBalance : String,
val syncFailed: String, val permGetInfo : String,
val scheduleForLater: String, val permMakeInvoice : String,
val scheduleSync: String, val permLookupInvoice : String,
val schedule: String, val permListTransactions : String,
val cancel: 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 package com.bitcointxoko.gudariwallet.i18n
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) { fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
"success" -> statusSuccess "success" -> history.statusSuccess
"pending" -> statusPending "pending" -> history.statusPending
"failed" -> statusFailed "failed" -> history.statusFailed
else -> statusUnknown else -> history.statusUnknown
} }
@@ -1,20 +1,15 @@
package com.bitcointxoko.gudariwallet.security package com.bitcointxoko.gudariwallet.security
import android.content.Context import android.content.Context
import timber.log.Timber
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.migrations.SharedPreferencesMigration import androidx.datastore.migrations.SharedPreferencesMigration
import androidx.datastore.migrations.SharedPreferencesView
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.File import java.io.File
import androidx.core.content.edit
/** /**
* Singleton DataStore for encrypted credentials. * Singleton DataStore for encrypted credentials.
@@ -27,8 +22,6 @@ import androidx.core.content.edit
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded. * [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
*/ */
object CredentialsDataStore { object CredentialsDataStore {
private const val TAG = "CredentialsDataStore"
@Volatile @Volatile
private var instance: DataStore<Credentials>? = null private var instance: DataStore<Credentials>? = null
@@ -25,8 +25,8 @@ class CredentialsSerializer(
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}") Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
result result
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e) Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
throw CorruptionException("Cannot read/decrypt credentials DataStore", e) throw CorruptionException("Cannot read/decrypt credentials DataStore")
} }
} }
@@ -37,12 +37,10 @@ class CredentialsSerializer(
val encryptingStream = streamingAead.newEncryptingStream(output, AAD) val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
encryptingStream.close() encryptingStream.close()
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " + Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " + "hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Encryption/write failed", e) Timber.e("Encryption/write failed")
throw e throw e
} }
} }
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
Timber.i("StreamingAead primitive acquired successfully") Timber.i("StreamingAead primitive acquired successfully")
} }
} catch (e: Exception) { } 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 throw e
} }
} }
@@ -41,7 +41,7 @@ class NodeAliasService(
repo.put(pubkey, alias) repo.put(pubkey, alias)
// 4. Publish to StateFlow // 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias) _aliases.value += (pubkey to alias)
Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"") Timber.d("ALIAS [FETCHED ] $pubkey\"$alias\"")
return alias return alias
@@ -7,8 +7,8 @@ import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.GsonProvider import com.bitcointxoko.gudariwallet.api.JsonProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
@@ -30,6 +30,7 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "WalletNotifService" private const val TAG = "WalletNotifService"
@@ -98,7 +99,7 @@ class WalletNotificationService : Service() {
private var reconnectAttempts = 0 private var reconnectAttempts = 0
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
private val gson = GsonProvider.gson private val json = JsonProvider.json
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -157,7 +158,7 @@ class WalletNotificationService : Service() {
val request = Request.Builder().url(wsUrl).build() 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) { override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.d("WebSocket connected") Timber.d("WebSocket connected")
@@ -193,7 +194,7 @@ class WalletNotificationService : Service() {
private fun handleMessage(text: String) { private fun handleMessage(text: String) {
val msg = runCatching { val msg = runCatching {
gson.fromJson(text, WsPaymentMessage::class.java) json.decodeFromString(WsPaymentMessage.serializer(), text)
}.getOrNull() ?: return }.getOrNull() ?: return
val payment = msg.payment ?: return val payment = msg.payment ?: return
@@ -271,7 +272,7 @@ class WalletNotificationService : Service() {
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = serviceScope.launch { reconnectJob = serviceScope.launch {
delay(backoff) delay(backoff.milliseconds)
openWebSocket() openWebSocket()
} }
} }
@@ -25,6 +25,8 @@ import androidx.compose.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable
import androidx.compose.material.icons.filled.People
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -38,6 +40,8 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
fun HomeTab( fun HomeTab(
vm: WalletViewModel, vm: WalletViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onNwcClick : () -> Unit,
onContactsClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -50,235 +54,258 @@ fun HomeTab(
val isRefreshing = balanceState is BalanceState.Loading val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true || (balanceState as? BalanceState.Success)?.isRefreshing == true
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() }, onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
when (val s = balanceState) { Column(
modifier = Modifier
is BalanceState.Loading -> { .fillMaxSize()
CircularProgressIndicator() .verticalScroll(rememberScrollState())
Spacer(Modifier.height(12.dp)) .padding(24.dp),
Text( horizontalAlignment = Alignment.CenterHorizontally,
text = strings.loadingBalance, verticalArrangement = Arrangement.Center
style = MaterialTheme.typography.bodyMedium, ) {
color = MaterialTheme.colorScheme.onSurfaceVariant when (val s = balanceState) {
)
}
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))
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text( Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats), text = strings.loadingBalance,
style = MaterialTheme.typography.displayLarge.copy( style = MaterialTheme.typography.bodyMedium,
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
),
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.width(8.dp))
} }
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency is BalanceState.Success -> {
if (currency != null) { val selectedCurrency by vm.selectedCurrency.collectAsState()
Row( var showCurrencyPicker by remember { mutableStateOf(false) }
verticalAlignment = Alignment.CenterVertically, var isLoadingCurrencies by remember { mutableStateOf(false) }
horizontalArrangement = Arrangement.Center var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
) { LaunchedEffect(showCurrencyPicker) {
if (s.fiatAmount != null && s.fiatCurrency != null) { if (showCurrencyPicker && currencyList.isEmpty()) {
Text( isLoadingCurrencies = true
text = if (balanceHidden) "••••" currencyList = vm.getSupportedCurrencies()
else formatFiat(s.fiatAmount, s.fiatCurrency), isLoadingCurrencies = false
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, Row(
modifier = Modifier verticalAlignment = Alignment.CenterVertically
.clickable( ) {
onClick = { showCurrencyPicker = true }, Icon(
onClickLabel = strings.changeCurrency, imageVector = if (balanceHidden)
indication = null, 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() } 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(
text = currency, text = strings.showFiat,
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
),
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( Spacer(Modifier.height(8.dp))
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) if (!s.isRefreshing) {
) { val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
}
Text( Text(
text = strings.showFiat, text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall, 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) { TextButton(onClick = onHistoryClick) {
val updatedLabel: String = remember(s.lastUpdated) { Text(
formatLastUpdated(s.lastUpdated, strings) 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)) // ── NEW: Language switcher ─────────────────────────────────
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
// Spacer(Modifier.height(8.dp)) // Spacer(Modifier.height(8.dp))
// LanguageSwitcherButton( // LanguageSwitcherButton(
// currentTag = lyricist.languageTag, // currentTag = lyricist.languageTag,
// supportedTags = supportedTags, // supportedTags = supportedTags,
// onSelect = { tag -> lyricist.languageTag = tag } // onSelect = { tag -> lyricist.languageTag = tag }
// ) // )
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
if (showCurrencyPicker) { if (showCurrencyPicker) {
CurrencyPickerSheet( CurrencyPickerSheet(
currencies = currencyList, currencies = currencyList,
selectedCurrency = selectedCurrency, selectedCurrency = selectedCurrency,
onSelect = { code -> onSelect = { code ->
vm.setSelectedCurrency(code) vm.setSelectedCurrency(code)
showCurrencyPicker = false showCurrencyPicker = false
}, },
isLoading = isLoadingCurrencies, isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false } onDismiss = { showCurrencyPicker = false }
) )
} }
} }
is BalanceState.Error -> { is BalanceState.Error -> {
if (s.staleSats != null) { if (s.staleSats != null) {
Text( Text(
text = if (balanceHidden) "••••••" else formatSats(s.staleSats), text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge.copy( style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
), ),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
) )
Text( Text(
text = strings.sats, text = strings.sats,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
Surface( Surface(
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f) modifier = Modifier.fillMaxWidth(0.85f)
) { ) {
Text( Text(
text = if (s.staleSats != null) text = if (s.staleSats != null)
strings.couldNotRefresh strings.couldNotRefresh
else else
strings.couldNotLoadBalance, strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp) 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 amountSats : Long,
val memo : String, val memo : String,
val expiresAt : Long val expiresAt : Long
) : ReceiveState() ) : ReceiveState()
data class PaymentReceived( data class PaymentReceived(
val amountSats: Long, val amountSats: Long,
val memo : String?, val memo : String?,
val checkingId : String val paymentHash : String
) : ReceiveState() ) : ReceiveState()
data class Error(val message: String) : ReceiveState() data class Error(val message: String) : ReceiveState()
data class LnurlWithdrawReady( data class LnurlWithdrawReady(
val k1 : String, val k1 : String,
val callback : String, val callback : String,
@@ -85,14 +85,18 @@ sealed class SendState {
override val routeHintAliases : Map<String, String> = emptyMap(), override val routeHintAliases : Map<String, String> = emptyMap(),
override val paymentHash : String, override val paymentHash : String,
override val createdAt : Instant, override val createdAt : Instant,
override val expiresAt : Instant override val expiresAt : Instant,
val contactId : String? = null
) : SendState(), DecodedInvoice ) : SendState(), DecodedInvoice
data object Paying : SendState() data object Paying : SendState()
data class PaymentSent( data class PaymentSent(
val amountSats : Long, val amountSats : Long,
val feeSats : Long? = null, val feeSats : Long? = null,
val paymentHash: String, val paymentHash: String,
val pendingRecord: PaymentRecord? = null val pendingRecord: PaymentRecord? = null,
val lnurl : String? = null,
val contactId : String? = null,
val contactName : String? = null
) : SendState() ) : SendState()
data class Error(val message: String) : SendState() data class Error(val message: String) : SendState()
} }
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.ui package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager import android.content.ClipboardManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -57,16 +59,26 @@ import cafe.adriel.lyricist.Lyricist
import com.bitcointxoko.gudariwallet.MainActivity import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.i18n.AppStrings import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel 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.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── Tab destinations ────────────────────────────────────────────────────────── // ── Tab destinations ──────────────────────────────────────────────────────────
// Note: label and icon are now resolved at the call site via LocalAppStrings, // 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_HOME = "home"
private const val ROUTE_SCANNER = "scanner" private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history" private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
private const val ROUTE_NWC = "nwc"
private const val ROUTE_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 @Composable
fun WalletScreen( fun WalletScreen(
vm : WalletViewModel, vm : WalletViewModel,
@@ -115,11 +133,25 @@ fun WalletScreen(
repo = vm.repo, repo = vm.repo,
aliasRepo = vm.aliasRepo, aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache, paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
feedRepository = PaymentFeedRepository(
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
nodeAliasRepository = vm.aliasRepo,
fiatVm = vm.fiatVm
),
fiatCurrency = vm.selectedCurrency, fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit fiatSatsPerUnit = vm.fiatSatsPerUnit
) )
} }
val historyVm: HistoryViewModel = viewModel(factory = historyFactory) val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
)
val contactsVm: ContactsViewModel = viewModel(
factory = ContactsViewModel.Factory(vm.contactRepo)
)
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -127,10 +159,18 @@ fun WalletScreen(
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER && currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY && currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL
&& currentRoute != ROUTE_CONTACTS
&& currentRoute != ROUTE_CONTACT_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER || currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL
|| currentRoute == ROUTE_CONTACTS
|| currentRoute == ROUTE_CONTACT_DETAIL
// ── Notification tap ────────────────────────────────────────────────────── // ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value val hash = pendingPaymentHash.value
@@ -192,7 +232,7 @@ fun WalletScreen(
LaunchedEffect(clipboardOffer) { LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) { if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000) delay(8_000.milliseconds)
vm.dismissClipboardOffer() vm.dismissClipboardOffer()
} }
} }
@@ -202,7 +242,7 @@ fun WalletScreen(
LaunchedEffect(nfcOffer) { LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) { if (nfcOffer is NfcOfferState.Detected) {
delay(8_000) delay(8_000.milliseconds)
nfcVm.dismissNfcOffer() nfcVm.dismissNfcOffer()
} }
} }
@@ -322,6 +362,14 @@ fun WalletScreen(
launchSingleTop = true launchSingleTop = true
} }
}, },
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
onContactsClick = {
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
},
lyricist = lyricist lyricist = lyricist
) )
} }
@@ -329,18 +377,23 @@ fun WalletScreen(
ReceiveScreen( ReceiveScreen(
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
onNavigateToPaymentDetail = { checkingId -> onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$checkingId") navController.navigate("payment_detail/$paymentHash")
} }
) )
} }
composable(TabItem.Send.route) { composable(TabItem.Send.route) {
SendScreen( SendScreen(
vm = vm, vm = vm,
nfcVm = nfcVm, nfcVm = nfcVm,
onViewDetails = { checkingId, record -> onViewDetails = { paymentHash, record ->
historyVm.primePayment(record) historyVm.primePayment(record)
navController.navigate("payment_detail/$checkingId") { navController.navigate("payment_detail/$paymentHash") {
launchSingleTop = true
}
},
onViewContact = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true launchSingleTop = true
} }
}, },
@@ -379,34 +432,30 @@ fun WalletScreen(
onClose = { onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false) navController.popBackStack(ROUTE_HOME, inclusive = false)
}, },
onPaymentClick = { payment -> onPaymentClick = { item ->
navController.navigate("payment_detail/${payment.checkingId}") navController.navigate("payment_detail/${item.paymentHash}")
} }
) )
} }
// Payment detail — back → history (natural back stack) // Payment detail — back → history (natural back stack)
composable( composable(
route = ROUTE_PAYMENT_DETAIL, route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType }) arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: "" val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val historyState by historyVm.filteredState.collectAsStateWithLifecycle() val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
val fallbackState by historyVm.state.collectAsStateWithLifecycle() val item = remember(feedItems, paymentHash) {
val payment = remember(historyState, fallbackState, checkingId) { historyVm.findPayment(paymentHash)
(historyState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
?: (fallbackState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
} }
if (payment != null) { if (item != null) {
PaymentDetailScreen( PaymentDetailScreen(
payment = payment, item = item,
vm = historyVm, vm = historyVm,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} else { } else {
LaunchedEffect(checkingId) { historyVm.refresh() } LaunchedEffect(paymentHash) { historyVm.refresh() }
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -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 ──── // ── 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.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
@@ -19,7 +23,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendEvent import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -28,6 +34,8 @@ class WalletViewModel(
val repo : WalletRepository, val repo : WalletRepository,
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
val nwcCache : NwcCacheRepository,
val contactRepo : ContactRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val lnAddressStore : LightningAddressStore, private val lnAddressStore : LightningAddressStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
@@ -97,7 +105,8 @@ class WalletViewModel(
amountMsat: Long, amountMsat: Long,
comment : String?, comment : String?,
strings : SendStrings, strings : SendStrings,
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings) contactId : String?
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings, contactId)
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) = fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
sendVm.payLnurlInvoice(state, strings) sendVm.payLnurlInvoice(state, strings)
@@ -109,6 +118,16 @@ class WalletViewModel(
val sendEvents: SharedFlow<SendEvent> = sendVm.events val sendEvents: SharedFlow<SendEvent> = sendVm.events
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
private val _pendingSend = MutableStateFlow<String?>(null)
val pendingSend: StateFlow<String?> get() = _pendingSend
/** Called by ContactDetailScreen before navigating to the Send tab. */
fun prefillSend(address: String) { _pendingSend.value = address }
/** Called by SendScreen once it has consumed the prefilled address. */
fun consumePendingSend() { _pendingSend.value = null }
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) { fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
receiveVm.handleWithdrawResponse(raw, lnurl) receiveVm.handleWithdrawResponse(raw, lnurl)
} }
@@ -124,6 +143,32 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text) fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer() fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
sendVm.sendState
.map { state ->
val lnurl = (state as? SendState.LnurlReady)?.lnurl
?: return@map null
contactRepo.findByLnAddress(lnurl)
?: contactRepo.findByLnurl(lnurl)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
}
}
init { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
@@ -11,6 +11,8 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.data.LightningAddressStore
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
@@ -24,17 +26,19 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app) val secrets = EncryptedSecretStore(app)
val api = LNbitsClient.create(secrets)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc) val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache) val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache) val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
val poller = PaymentPoller(repo) val poller = PaymentPoller(repo)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val nwcCache = NwcCacheRepository(app)
val contactRepo = ContactRepository(app)
val fiatDataStore = FiatDataStore(app) val fiatDataStore = FiatDataStore(app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val lnAddressStore = LightningAddressStore(app) val lnAddressStore = LightningAddressStore(app)
@@ -48,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
scanner = lnurlScanner, scanner = lnurlScanner,
payer = lnurlPayer, payer = lnurlPayer,
poller = poller, poller = poller,
contactRepo = contactRepo,
paymentCacheRepo = paymentCache
) )
val clipboardVm = ClipboardViewModel( val clipboardVm = ClipboardViewModel(
@@ -62,6 +68,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
repo, repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
nwcCache = nwcCache,
contactRepo = contactRepo,
balancePrefs = balancePrefs, balancePrefs = balancePrefs,
balanceVm = balanceVm, balanceVm = balanceVm,
fiatVm = fiatVm, fiatVm = fiatVm,
@@ -75,11 +75,10 @@ class BalanceViewModel(
runCatching { repo.getBalance() } runCatching { repo.getBalance() }
.onSuccess { balance -> .onSuccess { balance ->
val sats = balance.sats val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
when { null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete") sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
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})")
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
} }
_balanceState.value = BalanceState.Success( _balanceState.value = BalanceState.Success(
sats = sats, 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.content.ClipData
import android.graphics.Bitmap import android.graphics.Bitmap
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ContentCopy
import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share 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 onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired). * @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 @Composable
internal fun QrDisplayCard( internal fun QrDisplayCard(
modifier : Modifier = Modifier, modifier : Modifier = Modifier,
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
textToCopy : String, textToCopy : String,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, onToggleBrightness : () -> Unit,
isNfcEmulating : Boolean? = null, // null → no NFC button shown isNfcEmulating : Boolean? = null,
onToggleNfc : (() -> Unit)? = null, onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true, nfcEnabled : Boolean = true,
onOpenUri : (() -> Unit)? = null, // ← NEW
qrModifier : Modifier = Modifier.size(260.dp) qrModifier : Modifier = Modifier.size(260.dp)
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
// ── Action row: Share | Copy | Brightness | NFC ─────────────────────── // ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Share, imageVector = Icons.Filled.Share,
contentDescription = strings.qrShare, // ← "Share" contentDescription = strings.receive.qrShare,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ContentCopy, imageVector = Icons.Filled.ContentCopy,
contentDescription = strings.qrCopy, // ← "Copy" contentDescription = strings.receive.qrCopy,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
FilledTonalIconButton(onClick = onToggleBrightness) { FilledTonalIconButton(onClick = onToggleBrightness) {
Icon( Icon(
imageVector = Icons.Filled.WbSunny, imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness" contentDescription = if (brightnessOn) strings.receive.brightnessReduce
else strings.brightnessBoost, // ← "Boost brightness" else strings.receive.brightnessBoost,
tint = if (brightnessOn) MaterialTheme.colorScheme.primary tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
Icon( Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc imageVector = if (isNfcEmulating) Icons.Filled.Nfc
else Icons.Outlined.Nfc, else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing" contentDescription = if (isNfcEmulating) strings.receive.qrNfcStop
else strings.qrNfcStart, // ← "Share via NFC" else strings.receive.qrNfcStart,
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant 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 ───────────────────────────────────────────────────────────── // ── QR generation ─────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512 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.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -96,6 +96,17 @@ class FiatViewModel(
initialValue = null initialValue = null
) )
/** Combined fiat stream for [PaymentFeedRepository] feed assembly. */
val fiatFlow: StateFlow<Pair<String?, Double?>> = combine(
selectedCurrency,
fiatSatsPerUnit
) { currency, rate -> Pair(currency, rate) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = Pair(null, null)
)
// ── Called by BalanceViewModel after every successful balance refresh ───── // ── Called by BalanceViewModel after every successful balance refresh ─────
fun onBalanceRefreshed() { fun onBalanceRefreshed() {
viewModelScope.launch { fetchAndApplyFiatRate() } viewModelScope.launch { fetchAndApplyFiatRate() }
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.util.formatEpochShort
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -61,14 +62,14 @@ internal fun FilterBottomSheet(
) { ) {
// ── Sheet title ─────────────────────────────────────────────────── // ── Sheet title ───────────────────────────────────────────────────
Text( Text(
text = strings.filterSheetTitle, // ← "Filter payments" text = strings.history.filterSheetTitle, // ← "Filter payments"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Direction ───────────────────────────────────────────────────── // ── Direction ─────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionDirection, // ← "Direction" text = strings.history.filterSectionDirection, // ← "Direction"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -76,9 +77,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DirectionFilter.entries.forEach { option -> DirectionFilter.entries.forEach { option ->
val label = when (option) { val label = when (option) {
DirectionFilter.ALL -> strings.filterDirectionAll // ← "All" DirectionFilter.ALL -> strings.history.filterDirectionAll // ← "All"
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming" DirectionFilter.INCOMING -> strings.history.filterDirectionIncoming // ← "Incoming"
} }
FilterChip( FilterChip(
selected = currentFilter.direction == option, selected = currentFilter.direction == option,
@@ -91,7 +92,7 @@ internal fun FilterBottomSheet(
// ── Status ──────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionStatus, // ← "Status" text = strings.history.filterSectionStatus, // ← "Status"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -104,9 +105,9 @@ internal fun FilterBottomSheet(
label = { label = {
Text( Text(
when (option) { when (option) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed" StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending" StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed" StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
} }
) )
} }
@@ -118,7 +119,7 @@ internal fun FilterBottomSheet(
if (availableTypes.isNotEmpty()) { if (availableTypes.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = strings.filterSectionType, // ← "Type" text = strings.history.filterSectionType, // ← "Type"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -137,7 +138,7 @@ internal fun FilterBottomSheet(
// ── Amount (sats) ───────────────────────────────────────────────── // ── Amount (sats) ─────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionAmount, // ← "Amount (sats)" text = strings.history.filterSectionAmount, // ← "Amount (sats)"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -158,8 +159,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = minText, value = minText,
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMin_label) }, // ← "Min" label = { Text(strings.history.filterAmountMin_label) }, // ← "Min"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats" suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -168,8 +169,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = maxText, value = maxText,
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
label = { Text(strings.filterAmountMax_label) }, // ← "Max" label = { Text(strings.history.filterAmountMax_label) }, // ← "Max"
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats" suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -179,7 +180,7 @@ internal fun FilterBottomSheet(
// ── Date ────────────────────────────────────────────────────────── // ── Date ──────────────────────────────────────────────────────────
Text( Text(
text = strings.filterSectionDate, // ← "Date" text = strings.history.filterSectionDate, // ← "Date"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -189,9 +190,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DatePreset.entries.forEach { preset -> DatePreset.entries.forEach { preset ->
val label = when (preset) { val label = when (preset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week" DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month" DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year" DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
} }
FilterChip( FilterChip(
selected = currentFilter.datePreset == preset, selected = currentFilter.datePreset == preset,
@@ -219,8 +220,8 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.minCreatedAt currentFilter.minCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.filterDateFrom_label // ← "From" ?: strings.history.filterDateFrom_label // ← "From"
) )
} }
Text("", style = MaterialTheme.typography.bodyLarge) Text("", style = MaterialTheme.typography.bodyLarge)
@@ -230,8 +231,8 @@ internal fun FilterBottomSheet(
) { ) {
Text( Text(
currentFilter.maxCreatedAt currentFilter.maxCreatedAt
?.let { formatEpochShort(it) } ?.let { formatEpochShort(it, strings.today, strings.yesterday) }
?: strings.filterDateTo_label // ← "To" ?: strings.history.filterDateTo_label // ← "To"
) )
} }
} }
@@ -247,11 +248,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000) val epochSec = pickerState.selectedDateMillis?.div(1000)
onDateFilterSet(epochSec, currentFilter.maxCreatedAt) onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
showFromPicker = false showFromPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK" }) { Text(strings.history.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showFromPicker = false }) { TextButton(onClick = { showFromPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel" Text(strings.history.filterDialogCancel) // ← "Cancel"
} }
} }
) { DatePicker(state = pickerState) } ) { DatePicker(state = pickerState) }
@@ -269,11 +270,11 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L } val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
onDateFilterSet(currentFilter.minCreatedAt, epochSec) onDateFilterSet(currentFilter.minCreatedAt, epochSec)
showToPicker = false showToPicker = false
}) { Text(strings.filterDialogOk) } // ← "OK" }) { Text(strings.history.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showToPicker = false }) { TextButton(onClick = { showToPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel" Text(strings.history.filterDialogCancel) // ← "Cancel"
} }
} }
) { DatePicker(state = pickerState) } ) { 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.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import com.bitcointxoko.gudariwallet.util.formatEpochShort
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── List screen ────────────────────────────────────────────────────────────── // ── List screen ──────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
vm : HistoryViewModel, vm : HistoryViewModel,
onClose : () -> Unit, onClose : () -> Unit,
onPaymentClick : (PaymentRecord) -> Unit onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val state by vm.filteredState.collectAsState() val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
val filter by vm.filter.collectAsState() val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
val fiatCurrency by vm.fiatCurrency.collectAsState() val filter by vm.filter.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val availableTypes by vm.availableTypes.collectAsState()
val availableTypes by vm.availableTypes.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) } // fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
val dismissFilterSheet = { filterSheetVisible = false } // they are now embedded in each PaymentFeedItem
var searchVisible by rememberSaveable { mutableStateOf(false) }
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false }
var searchVisible by rememberSaveable { mutableStateOf(false) }
var searchInfoSheetVisible by remember { mutableStateOf(false) } var searchInfoSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isFiltered = filter.isActive val isFiltered = filter.isActive
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(strings.historyTitle) }, // ← "History" title = { Text(strings.history.historyTitle) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historyClose // ← "Close" contentDescription = strings.history.historyClose
) )
} }
}, },
@@ -107,7 +110,7 @@ fun HistoryScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = strings.historySearchPayments // ← "Search payments" contentDescription = strings.history.historySearchPayments
) )
} }
} }
@@ -117,7 +120,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) { IconButton(onClick = { filterSheetVisible = true }) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = strings.historyFilterPayments // ← "Filter payments" contentDescription = strings.history.historyFilterPayments
) )
} }
} }
@@ -138,7 +141,7 @@ fun HistoryScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
// ── Search bar ─────────────────────────────────────────────────── // ── Search bar ─────────────────────────────────────────────────
if (searchVisible) { if (searchVisible) {
SearchBar( SearchBar(
query = filter.searchQuery, query = filter.searchQuery,
@@ -152,7 +155,7 @@ fun HistoryScreen(
HorizontalDivider() HorizontalDivider()
} }
// ── Active filter chips ────────────────────────────────────────── // ── Active filter chips ────────────────────────────────────────
val searchActive = filter.searchQuery.isNotBlank() val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) { if (isFiltered || searchActive) {
ActiveFilterChips( ActiveFilterChips(
@@ -170,14 +173,84 @@ fun HistoryScreen(
) )
} }
// ── Content ────────────────────────────────────────────────────── // ── Content ────────────────────────────────────────────────────
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
is HistoryState.Loading -> { // ── List — always rendered when state is Success, never unmounted ──────
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) val s = state
if (s is HistoryState.Success) {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
} }
if (items.isEmpty()) {
Text(
text = strings.history.historyNoPayments,
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = items,
key = { it.paymentHash },
contentType = { "payment" }
) { item ->
PaymentRow(
item = item,
onClick = { onPaymentClick(item) }
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
if (s.canLoadMore) {
item(contentType = "loader") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
}
}
}
}
}
}
// ── State overlays — sit on top of the list, never destroy it ─────────
when (val s = state) {
is HistoryState.Loading -> {
// Only show the full-screen spinner on the very first load
// (when we have no items yet). During loadMore(), the in-list
// footer spinner handles it — don't obscure the results.
if (items.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
is HistoryState.Error -> { is HistoryState.Error -> {
Column( Column(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
@@ -190,95 +263,33 @@ fun HistoryScreen(
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Button(onClick = { vm.refresh() }) {
Text(strings.historyRetry) // ← "Retry" Text(strings.history.historyRetry)
}
}
}
is HistoryState.Success -> {
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
val totalItems = listState.layoutInfo.totalItemsCount
lastVisible != null && lastVisible.index >= totalItems - 8
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
}
if (s.payments.isEmpty()) {
Text(
text = strings.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
)
}
}
}
}
} }
} }
} }
is HistoryState.Success -> { /* handled above */ }
} }
} }
} }
} }
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
if (filterSheetVisible) { if (filterSheetVisible) {
FilterBottomSheet( FilterBottomSheet(
currentFilter = filter, currentFilter = filter,
availableTypes = availableTypes, availableTypes = availableTypes,
sheetState = sheetState, sheetState = sheetState,
onDismiss = dismissFilterSheet, onDismiss = dismissFilterSheet,
onDirectionSelected = { direction -> onDirectionSelected = { direction ->
vm.setDirectionFilter(direction) vm.setDirectionFilter(direction)
dismissFilterSheet() dismissFilterSheet()
}, },
onStatusToggled = { vm.toggleStatusFilter(it) }, onStatusToggled = { vm.toggleStatusFilter(it) },
onTypeToggled = { vm.toggleTypeFilter(it) }, onTypeToggled = { vm.toggleTypeFilter(it) },
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) }, onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
onDatePresetSelected = { vm.applyDatePreset(it) }, onDatePresetSelected = { vm.applyDatePreset(it) },
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) }, onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
onDateClearRequested = { vm.clearDateFilter() } onDateClearRequested = { vm.clearDateFilter() }
) )
} }
@@ -288,7 +299,7 @@ fun HistoryScreen(
) )
} }
// ── Search bar ─────────────────────────────────────────────────────────────── // ── Search bar ───────────────────────────────────────────────────────────────
@Composable @Composable
private fun SearchBar( private fun SearchBar(
query : String, query : String,
@@ -309,13 +320,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) { IconButton(onClick = onInfoClick) {
Icon( Icon(
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
contentDescription = strings.historySearchHelp // ← "Search help" contentDescription = strings.history.historySearchHelp
) )
} }
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions" placeholder = { Text(strings.history.historySearchPlaceholder) },
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -336,7 +347,7 @@ private fun SearchBar(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ContentPaste, imageVector = Icons.Default.ContentPaste,
contentDescription = strings.historySearchPaste // ← "Paste" contentDescription = strings.history.historySearchPaste
) )
} }
IconButton( IconButton(
@@ -345,7 +356,7 @@ private fun SearchBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historySearchClear, // ← "Clear search" contentDescription = strings.history.historySearchClear,
tint = if (query.isNotEmpty()) LocalContentColor.current tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
) )
@@ -353,22 +364,22 @@ private fun SearchBar(
} }
} }
// ── Active filter chips ────────────────────────────────────────────────────── // ── Active filter chips ──────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ActiveFilterChips( private fun ActiveFilterChips(
filter : PaymentFilter, filter : PaymentFilter,
searchActive : Boolean, searchActive : Boolean,
onClearDirection: () -> Unit, onClearDirection : () -> Unit,
onClearStatus : (StatusFilter) -> Unit, onClearStatus : (StatusFilter) -> Unit,
onClearType : (String) -> Unit, onClearType : (String) -> Unit,
onClearAmount : () -> Unit, onClearAmount : () -> Unit,
onClearDate : () -> Unit, onClearDate : () -> Unit,
onClearSearch : () -> Unit onClearSearch : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
FlowRow( FlowRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -381,15 +392,15 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (filter.direction) { when (filter.direction) {
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
else -> strings.filterDirectionIncoming // ← "Incoming" else -> strings.history.filterDirectionIncoming
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDirection, // ← "Clear direction filter" contentDescription = strings.history.filterClearDirection,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -402,16 +413,16 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (s) { when (s) {
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed" StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending" StatusFilter.PENDING -> strings.history.filterStatusPending
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed" StatusFilter.FAILED -> strings.history.filterStatusFailed
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter" contentDescription = strings.history.filterRemoveStatus,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -425,7 +436,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveType, // ← "Remove type filter" contentDescription = strings.history.filterRemoveType,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -434,11 +445,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) { if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when { val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null -> filter.minAmountSat != null && filter.maxAmountSat != null ->
strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats" strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
filter.minAmountSat != null -> filter.minAmountSat != null ->
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats" strings.history.filterAmountMin(filter.minAmountSat)
else -> else ->
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats" strings.history.filterAmountMax(filter.maxAmountSat!!)
} }
InputChip( InputChip(
selected = true, selected = true,
@@ -447,7 +458,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearAmount, // ← "Clear amount filter" contentDescription = strings.history.filterClearAmount,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -457,19 +468,23 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) { if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) { val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week" DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month" DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year" DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.filterDateRange( // ← "XY" strings.history.filterDateRange(
formatEpochShort(minCreatedAt), formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
formatEpochShort(maxCreatedAt) formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
) )
minCreatedAt != null -> minCreatedAt != null ->
strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X" strings.history.filterDateFrom(
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
)
else -> else ->
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" strings.history.filterDateUntil(
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
)
} }
} }
InputChip( InputChip(
@@ -479,7 +494,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDate, // ← "Clear date filter" contentDescription = strings.history.filterClearDate,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -489,11 +504,11 @@ private fun ActiveFilterChips(
InputChip( InputChip(
selected = true, selected = true,
onClick = onClearSearch, onClick = onClearSearch,
label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX" label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historyClearSearch, // ← "Clear search" contentDescription = strings.history.historyClearSearch,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -4,36 +4,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.isFailedStatus
// ── Pure helpers ───────────────────────────────────────────────────────────── import com.bitcointxoko.gudariwallet.util.isPendingStatus
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 ───────────────────────────────────────────────────────
@Composable @Composable
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color { internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
@@ -5,199 +5,464 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.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.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel" private const val TAG = "HistoryViewModel"
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class HistoryViewModel( class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
val fiatCurrency : StateFlow<String?>, private val contactRepo : ContactRepository,
val fiatSatsPerUnit : StateFlow<Double?> private val feedRepository : PaymentFeedRepository,
val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModel() { ) : ViewModel() {
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
// ── Sync manager ──────────────────────────────────────────────────────────
// Owns the websocket/sync loop, pagination, and optimistic inserts.
// Still operates on PaymentRecord internally — untouched by this refactor.
private val syncManager: PaymentSyncManager = PaymentSyncManager( private val syncManager: PaymentSyncManager = PaymentSyncManager(
repo = repo, repo = repo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope, scope = viewModelScope,
onNewPayments = { payments -> enricher.enrich(payments) } onNewPayments = { payments -> enricher.enrich(payments) }
) )
// ── Optimistic insert (called after a successful send) ───────────────────── // ── Optimistic insert (called after a successful send) ─────────────────────
fun primePayment(record: PaymentRecord) { fun primePayment(record: PaymentRecord) {
syncManager.primePayment(record) syncManager.primePayment(record)
} }
// ── Enricher ──────────────────────────────────────────────────────────────
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
// which feeds into the feedRepository.observeFeedItems() combine.
private val enricher: PaymentEnricher = PaymentEnricher( private val enricher: PaymentEnricher = PaymentEnricher(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope, scope = viewModelScope,
onEnriched = { paymentHash, pubkey, alias -> onEnriched = { paymentHash, pubkey, alias ->
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias) syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
} }
) )
// ── Sync status ───────────────────────────────────────────────────────────
// Exposes Loading / Error / Success shell (isRefreshing, canLoadMore).
// Does NOT carry payment data — see feedItems below.
val state: StateFlow<HistoryState> = syncManager.state val state: StateFlow<HistoryState> = syncManager.state
fun refresh() = syncManager.refresh()
fun refresh() = syncManager.refresh()
fun loadMore() = syncManager.loadMore( fun loadMore() = syncManager.loadMore(
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true } filteredCanLoadMore = {
(state.value as? HistoryState.Success)?.canLoadMore == true
}
) )
// ── Detail delegate ─────────────────────────────────────────────────────── // ── Detail delegate ───────────────────────────────────────────────────────
private val detailDelegate = PaymentDetailDelegate( private val detailDelegate = PaymentDetailDelegate(
repo = repo, repo = repo,
aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
scope = viewModelScope scope = viewModelScope
) )
val detailState: StateFlow<DetailState> = detailDelegate.state val detailState: StateFlow<DetailState> = detailDelegate.state
fun loadDetail(checkingId: String, record: PaymentRecord? = null) = fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
detailDelegate.load(checkingId, record) detailDelegate.load(paymentHash, record)
fun clearDetail() = detailDelegate.clear() fun clearDetail() = detailDelegate.clear()
// ── Filter state ────────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────────
private val _filter = MutableStateFlow(PaymentFilter()) private val _filter = MutableStateFlow(PaymentFilter())
val filter: StateFlow<PaymentFilter> = _filter val filter: StateFlow<PaymentFilter> = _filter
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } } fun setSearchQuery(q: String) {
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } } _filter.update { it.copy(searchQuery = q.trim()) }
fun setDirectionFilter(direction: DirectionFilter) {
_filter.update { it.copy(direction = direction) }
} }
fun toggleStatusFilter(status: StatusFilter) {
fun clearSearch() {
_filter.update { it.copy(searchQuery = "") }
}
fun setDirectionFilter(d: DirectionFilter) {
_filter.update { it.copy(direction = d) }
}
fun toggleStatusFilter(s: StatusFilter) {
_filter.update { f -> _filter.update { f ->
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status val updated = if (s in f.statuses) f.statuses - s else f.statuses + s
f.copy(statuses = updated) f.copy(statuses = updated)
} }
} }
fun toggleTypeFilter(type: String) { fun toggleTypeFilter(type: String) {
_filter.update { f -> _filter.update { f ->
val updated = if (type in f.types) f.types - type else f.types + type val updated = if (type in f.types) f.types - type else f.types + type
f.copy(types = updated) f.copy(types = updated)
} }
} }
fun setAmountFilter(min: Long?, max: Long?) { fun setAmountFilter(min: Long?, max: Long?) {
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) } _filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
} }
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) { fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) } _filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
} }
fun applyDatePreset(preset: DatePreset) { fun applyDatePreset(preset: DatePreset) {
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt val (min, max) = preset.toEpochSecondRange()
setDateFilter(min, max, preset) setDateFilter(min, max, preset)
} }
fun clearDateFilter() { fun clearDateFilter() {
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) } _filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
} }
fun findPayment(checkingId: String): PaymentRecord? { fun toggleContactFilter(contactId: String) {
val fromFiltered = (filteredState.value as? HistoryState.Success) _filter.update { f ->
?.payments?.firstOrNull { it.checkingId == checkingId } val updated = if (contactId in f.contactIds)
if (fromFiltered != null) return fromFiltered f.contactIds - contactId else f.contactIds + contactId
return (state.value as? HistoryState.Success) f.copy(contactIds = updated)
?.payments?.firstOrNull { it.checkingId == checkingId } }
}
fun clearContactFilter() {
_filter.update { it.copy(contactIds = emptySet()) }
} }
// ── Room-backed query results ────────────────────────────────────────────── // ── 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 ───────────────────────────────────────────────────────── // ── Feed items — single source of truth for the UI list ───────────────────
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
/** Distinct non-null tags present in the currently loaded list. */ // source changes: payments DB, contact links, alias cache, fiat rate,
val availableTypes: StateFlow<List<String>> = syncManager.state // active filter, or Room query override.
.map { s -> val feedItems: StateFlow<List<PaymentFeedItem>> = combine(
if (s !is HistoryState.Success) emptyList() feedRepository.observeFeedItems(),
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted() _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()) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults -> // ── Available type filter chips ────────────────────────────────────────────
if (s !is HistoryState.Success) return@combine s val availableTypes: StateFlow<List<String>> = feedItems
if (roomResults == null) { .map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
s.copy(payments = s.payments.applyInMemoryFilters(f)) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
} else {
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
// ── In-memory filter logic ──────────────────────────────────────────────── // ── Find a single item by paymentHash ─────────────────────────────────────
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) = // 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 } distinctBy { it.paymentHash }
.let { list -> .let { list ->
when (f.direction) { when (f.direction) {
DirectionFilter.ALL -> list DirectionFilter.ALL -> list
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing } DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
DirectionFilter.INCOMING -> 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 -> .let { list ->
if (f.types.isEmpty()) 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 { init {
syncManager.init() syncManager.init()
// ── Room query path ────────────────────────────────────────────────────
// Activated when the filter requires DB-level text search or contact-ID
// lookup. Results are stored in _roomResults and override the live feed
// stream in feedItems. Cleared back to null when no Room query is needed.
viewModelScope.launch { viewModelScope.launch {
combine( combine(
_filter.map { it.searchQuery }.debounce(300), _filter.map { it.searchQuery }.debounce(300.milliseconds),
_filter _filter
) { _, f -> f } ) { _, f -> f }
.distinctUntilChanged()
.collect { f -> .collect { f ->
if (!f.needsRoomQuery()) { if (!f.needsRoomQuery()) {
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
_roomResults.value = null _roomResults.value = null
} else { return@collect
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)
} }
// 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 { viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event -> WalletNotificationService.paymentEvents.collect { event ->
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list") Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
syncManager.refresh() syncManager.refresh()
} }
} }
} }
// ── Private helper ─────────────────────────────────────────────────────────
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
// Contact links, alias, and fiat are intentionally left empty/null here —
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
// for all items that pass through the live feed path.
private fun PaymentRecord.toFeedItem(
contactSnapshot: Map<String, List<ContactSummary>> = emptyMap()
): PaymentFeedItem =
this.toPaymentFeedItem(
linkedContacts = contactSnapshot[paymentHash] ?: emptyList(),
resolvedAlias = pubkey?.let { aliasRepo.aliases.value[it] },
fiatCurrency = fiatCurrency.value,
fiatSatsPerUnit = fiatSatsPerUnit.value
)
} }
// ── Factory ─────────────────────────────────────────────────────────────────── // ── Factory ───────────────────────────────────────────────────────────────────
class HistoryViewModelFactory( class HistoryViewModelFactory(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val fiatCurrency : StateFlow<String?>, private val contactRepo : ContactRepository,
private val fiatSatsPerUnit: StateFlow<Double?> private val feedRepository : PaymentFeedRepository,
private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
"Unknown ViewModel class: ${modelClass.name}"
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return HistoryViewModel( return HistoryViewModel(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
contactRepo = contactRepo,
feedRepository = feedRepository,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit
) as T ) as T
} }
} }
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
import timber.log.Timber import timber.log.Timber
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
class PaymentDetailDelegate( class PaymentDetailDelegate(
private val repo : WalletRepository, private val repo : WalletRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val aliasRepo : NodeAliasRepository,
private val scope : CoroutineScope private val scope : CoroutineScope
) { ) {
private val _state = MutableStateFlow<DetailState>(DetailState.Idle) private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
fun load(checkingId: String, record: PaymentRecord? = null) { fun load(checkingId: String, record: PaymentRecord? = null) {
paymentCache.getCachedDetail(checkingId)?.let { paymentCache.getCachedDetail(checkingId)?.let {
Timber.d("DETAIL [HIT] $checkingId — instant from cache") Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache")
_state.value = DetailState.Success(it) _state.value = DetailState.Success(it)
return return
} }
_state.value = if (record != null) { _state.value = if (record != null) {
Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord") Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
DetailState.Partial(record) DetailState.Partial(record)
} else { } else {
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner") Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
DetailState.Loading DetailState.Loading
} }
scope.launch { scope.launch {
val fromDb = paymentCache.getDetailFromDb(checkingId) val fromDb = paymentCache.getDetailFromDb(checkingId)
if (fromDb != null) { if (fromDb != null) {
paymentCache.saveDetail(checkingId, fromDb) val record = fromDb.details
_state.value = DetailState.Success(fromDb) val enriched = if (
Timber.d("DETAIL [DB] $checkingId — served from Room") record != null &&
record.pubkey.isNullOrBlank() &&
!record.bolt11.isNullOrBlank()
) {
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey")
val pubkey = runCatching {
repo.decodeBolt11(record.bolt11!!).payee
}.getOrNull()
if (pubkey != null) {
val alias = runCatching {
aliasRepo.resolve(pubkey)
}.getOrNull()
paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias)
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias")
fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias))
} else {
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed")
fromDb
}
} else {
fromDb
}
paymentCache.saveDetail(checkingId, enriched)
_state.value = DetailState.Success(enriched)
Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room")
return@launch return@launch
} }
runCatching { repo.getPaymentDetail(checkingId) } runCatching { repo.getPaymentDetail(checkingId) }
.onSuccess { detail -> .onSuccess { detail ->
Timber.d("DETAIL [NET] $checkingId — fetched from network") Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
paymentCache.saveDetail(checkingId, detail) paymentCache.saveDetail(checkingId, detail)
_state.value = DetailState.Success(detail) _state.value = DetailState.Success(detail)
} }
.onFailure { e -> .onFailure { e ->
Timber.w("DETAIL [ERR] $checkingId${e.message}") Timber.tag(TAG).w("DETAIL [ERR] $checkingId${e.message}")
if (_state.value !is DetailState.Partial) { if (_state.value !is DetailState.Partial) {
_state.value = DetailState.Error(e.message ?: "Failed to load payment") _state.value = DetailState.Error(e.message ?: "Failed to load payment")
} }
} }
} }
} }
fun clear() { _state.value = DetailState.Idle } fun clear() { _state.value = DetailState.Idle }
} }
@@ -6,24 +6,30 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.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.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -33,8 +39,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
@@ -42,43 +50,88 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.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.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// ── Detail screen ──────────────────────────────────────────────────────────── // ── Detail screen ────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PaymentDetailScreen( fun PaymentDetailScreen(
payment : PaymentRecord, item : PaymentFeedItem, // ← was: payment: PaymentRecord
vm : HistoryViewModel, vm : HistoryViewModel,
onBack : () -> Unit onBack : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState() val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) } // fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
// no need to collect them from the ViewModel separately.
val fiatCurrency = item.fiatCurrency
val fiatSatsPerUnit = item.fiatSatsPerUnit
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
DisposableEffect(Unit) { onDispose { vm.clearDetail() } } DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
// Enrich the base PaymentFeedItem's backing record with the detail response.
// PaymentDetailContent still operates on PaymentRecord for technical fields.
// enriched falls back through partial → base PaymentRecord via toDomain()
// if the detail fetch is still in flight or has failed.
val enriched: PaymentRecord = when (val s = detailState) { val enriched: PaymentRecord = when (val s = detailState) {
is DetailState.Success -> { is DetailState.Success -> {
val detail = s.detail.details val detail = s.detail.details
when { when {
detail == null -> payment detail == null -> item.toBaseRecord()
else -> detail.copy( else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11 bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
) )
} }
} }
is DetailState.Partial -> s.record is DetailState.Partial -> s.record.let { r ->
else -> payment r.copy(
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
)
}
else -> item.toBaseRecord()
}
// ── Contact state ──────────────────────────────────────────────────────────
val linkedContacts by remember(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( Scaffold(
@@ -86,15 +139,15 @@ fun PaymentDetailScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment" if (item.isOutgoing) strings.history.detailOutgoingTitle
else strings.detailIncomingTitle // ← "Incoming payment" else strings.history.detailIncomingTitle
) )
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.detailBack // ← "Back" contentDescription = strings.history.detailBack
) )
} }
} }
@@ -116,7 +169,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = strings.detailCouldNotLoad, // ← "Could not load full details" text = strings.history.detailCouldNotLoad,
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
@@ -125,34 +178,86 @@ fun PaymentDetailScreen(
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(item.paymentHash, contactId)
}
) )
} }
} }
else -> { else -> {
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
modifier = Modifier.padding(padding) linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(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 @Composable
private fun PaymentDetailContent( private fun PaymentDetailContent(
payment : PaymentRecord, payment : PaymentRecord,
fiatCurrency : String?, fiatCurrency : String?,
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(
isPending = payment.pending,
isFailed = payment.status == "failed",
isOutgoing = payment.isOutgoing
)
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
@@ -178,7 +283,7 @@ private fun PaymentDetailContent(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ── Hero amount ────────────────────────────────────────────────────── // ── Hero amount ────────────────────────────────────────────────────────
item { item {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -187,7 +292,7 @@ private fun PaymentDetailContent(
) { ) {
Icon( Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward, else Icons.Default.ArrowDownward,
contentDescription = null, contentDescription = null,
tint = amountColor, tint = amountColor,
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
@@ -202,16 +307,13 @@ private fun PaymentDetailContent(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) )
if (payment.feeSat > 0) { if (payment.feeSat > 0) {
val ppm = feePpm( val ppm = feePpm(
amountMsat = kotlin.math.abs(payment.amountMsat), amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat) feeMsat = kotlin.math.abs(payment.feeMsat)
) )
// Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else "" val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else "" val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm" val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
payment.feeSat, fiatPart, ppmPart
)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = feeLabel, text = feeLabel,
@@ -225,39 +327,129 @@ private fun PaymentDetailContent(
} }
} }
// ── Basic info ─────────────────────────────────────────────────────── // ── Contact section ────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.detailSectionDetails) { // ← "Details" if (linkedContacts.isEmpty()) {
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date" 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( DetailRow(
strings.detailRowMemo, // ← "Memo" strings.history.detailRowDate,
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—" formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
)
DetailRow(
strings.history.detailRowMemo,
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
) )
if (payment.extra?.tag != null) { if (payment.extra?.tag != null) {
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type" DetailRow(strings.history.detailRowType, payment.extra.tag)
} }
if (payment.extra?.comment?.isNotBlank() == true) { if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment" DetailRow(strings.history.detailRowComment, payment.extra.comment)
} }
} }
} }
// ── Success action (LNURL) ─────────────────────────────────────────── // ── Success action (LNURL) ─────────────────────────────────────────────
payment.extra?.successAction?.let { action -> payment.extra?.successAction?.let { action ->
item { item {
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action" DetailSection(title = strings.history.detailSectionSuccessAction) {
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message" action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL" action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description" action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
} }
} }
} }
// ── Technical ──────────────────────────────────────────────────────── // ── Technical ────────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical" DetailSection(title = strings.history.detailSectionTechnical) {
DetailRow( DetailRow(
label = strings.detailRowPaymentHash, // ← "Payment Hash" label = strings.history.detailRowPaymentHash,
value = payment.paymentHash, value = payment.paymentHash,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -271,7 +463,7 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = strings.detailRowPreimage, // ← "Preimage" label = strings.history.detailRowPreimage,
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -288,37 +480,21 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = strings.detailRowDestination, // ← "Destination" label = strings.history.detailRowDestination,
value = label, value = label,
valueColor = MaterialTheme.colorScheme.primary, valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = { onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}, },
trailingContent = { trailingContent = {
IconButton( CopyIconButton(label = "node_id", value = pubkey)
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
)
}
} }
) )
} }
if (!payment.bolt11.isNullOrBlank()) { if (!payment.bolt11.isNullOrBlank()) {
DetailRow( DetailRow(
label = strings.detailRowBolt11, // ← "BOLT11" label = strings.history.detailRowBolt11,
value = payment.bolt11, value = payment.bolt11,
monospace = true, monospace = true,
copyable = true, copyable = true,
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.history package com.bitcointxoko.gudariwallet.ui.history
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
@@ -14,7 +15,8 @@ data class PaymentFilter(
val maxAmountSat : Long? = null, val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null, val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null, val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null val datePreset : DatePreset? = null,
val contactIds : Set<String> = emptySet()
) )
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
@@ -29,12 +31,16 @@ val PaymentFilter.isActive: Boolean
|| maxAmountSat != null || maxAmountSat != null
|| minCreatedAt != null || minCreatedAt != null
|| maxCreatedAt != null || maxCreatedAt != null
|| contactIds.isNotEmpty()
fun PaymentFilter.needsRoomQuery(): Boolean = fun PaymentFilter.needsRoomQuery(): Boolean =
searchQuery.isNotBlank() || searchQuery.isNotBlank() ||
statuses.isNotEmpty() || statuses.isNotEmpty() ||
minAmountSat != null || maxAmountSat != null || minAmountSat != null ||
minCreatedAt != null || maxCreatedAt != null maxAmountSat != null ||
minCreatedAt != null ||
maxCreatedAt != null ||
contactIds.isNotEmpty()
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */ /** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> { fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
@@ -60,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
if (f.types.isEmpty()) list if (f.types.isEmpty()) list
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { record ->
f.statuses.any { status ->
when (status) {
StatusFilter.COMPLETED -> !record.pending && record.status == "complete"
StatusFilter.PENDING -> record.pending
StatusFilter.FAILED -> record.status == "failed"
}
}
}
}
fun List<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
distinctBy { it.paymentHash }
.let { list ->
when (f.direction) {
DirectionFilter.ALL -> list
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
}
}
.let { list ->
if (f.types.isEmpty()) list
else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization
}
.let { list ->
if (f.statuses.isEmpty()) list
else list.filter { item ->
f.statuses.any { status ->
when (status) {
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
StatusFilter.PENDING -> item.isPending
StatusFilter.FAILED -> item.isFailed
}
}
}
}
.let { list ->
if (f.searchQuery.isBlank()) list
else list.filter { item ->
item.memo?.contains(f.searchQuery, ignoreCase = true) == true
|| item.contactName?.contains(f.searchQuery, ignoreCase = true) == true
|| item.alias?.contains(f.searchQuery, ignoreCase = true) == true
|| item.paymentHash.startsWith(f.searchQuery, ignoreCase = true)
}
}
@@ -22,29 +22,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
@Composable @Composable
internal fun PaymentRow( internal fun PaymentRow(
payment : PaymentRecord, item : PaymentFeedItem,
fiatCurrency : String?, onClick : () -> Unit
fiatSatsPerUnit : Double?,
onClick : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val isPending = isPendingStatus(payment.status) val semantic = semanticColors()
val isFailed = isFailedStatus(payment.status)
val semantic = semanticColors()
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) { val amountColor = paymentAmountColor(
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0) isPending = item.isPending,
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency) isFailed = item.isFailed,
isOutgoing = item.isOutgoing
)
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
else null else null
} }
@@ -56,45 +58,47 @@ internal fun PaymentRow(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent" contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
else strings.paymentRowReceivedCD, // ← "Received" else strings.history.paymentRowReceivedCD,
tint = amountColor, tint = amountColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } text = item.contactName // contact name first
?: strings.paymentRowNoMemo, // ← "No memo" ?: item.memo?.takeIf { it.isNotBlank() } // then memo
?: strings.history.paymentRowNoMemo, // then fallback
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) { val formattedTime = remember(item.createdAt ?: item.time) {
formatTimestamp(payment.createdAt, payment.time) formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
} }
Text( Text(
text = formattedTime, text = formattedTime,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val statusLabel = when { val statusLabel = when {
isPending -> strings.paymentRowPending item.isPending -> strings.history.paymentRowPending
isFailed -> strings.paymentRowFailed item.isFailed -> strings.history.paymentRowFailed
else -> null else -> null
} }
val statusColor = when { val statusColor = when {
isPending -> semantic.warningContainer item.isPending -> semantic.warningContainer
isFailed -> semantic.errorContainer item.isFailed -> semantic.errorContainer
else -> semantic.successContainer // fallback, won't be used else -> semantic.successContainer
} }
val statusContentColor = when { val statusContentColor = when {
isPending -> semantic.onWarningContainer item.isPending -> semantic.onWarningContainer
isFailed -> semantic.onErrorContainer item.isFailed -> semantic.onErrorContainer
else -> semantic.onSuccessContainer else -> semantic.onSuccessContainer
} }
if (statusLabel != null) { if (statusLabel != null) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -113,10 +117,25 @@ internal fun PaymentRow(
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
AmountWithFiatColumn( AmountWithFiatColumn(
amountSat = payment.amountSat, amountSat = item.amountSat,
isOutgoing = payment.isOutgoing, isOutgoing = item.isOutgoing,
amountColor = amountColor, amountColor = amountColor,
fiatLine = fiatLine fiatLine = fiatLine
) )
} }
} }
@Composable
internal fun paymentAmountColor(
isPending : Boolean,
isFailed : Boolean,
isOutgoing : Boolean
): androidx.compose.ui.graphics.Color {
val semantic = semanticColors()
return when {
isPending -> semantic.onWarningContainer
isFailed -> MaterialTheme.colorScheme.error
isOutgoing -> MaterialTheme.colorScheme.onSurface
else -> semantic.onSuccessContainer
}
}
@@ -159,8 +159,7 @@ class PaymentSyncManager(
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
if (reachedOverlap || reachedEnd) { if (reachedOverlap || reachedEnd) {
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " + Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
if (reachedOverlap) "stopped at known record" else "reached end of history")
break break
} }
@@ -33,45 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp) .padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
) { ) {
Text( Text(
text = strings.searchInfoTitle, // ← "What can I search for?" text = strings.history.searchInfoTitle, // ← "What can I search for?"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = strings.searchInfoSubtitle, // ← "Type any part of the following…" text = strings.history.searchInfoSubtitle, // ← "Type any part of the following…"
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoMemoTitle, // ← "Memo" title = strings.history.searchInfoMemoTitle, // ← "Memo"
description = strings.searchInfoMemoDesc description = strings.history.searchInfoMemoDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoInvoiceTitle, // ← "Invoice" title = strings.history.searchInfoInvoiceTitle, // ← "Invoice"
description = strings.searchInfoInvoiceDesc description = strings.history.searchInfoInvoiceDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoHashTitle, // ← "Hash" title = strings.history.searchInfoHashTitle, // ← "Hash"
description = strings.searchInfoHashDesc description = strings.history.searchInfoHashDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoPreimageTitle, // ← "Preimage" title = strings.history.searchInfoPreimageTitle, // ← "Preimage"
description = strings.searchInfoPreimageDesc description = strings.history.searchInfoPreimageDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoNodeAliasTitle, // ← "Node alias" title = strings.history.searchInfoNodeAliasTitle, // ← "Node alias"
description = strings.searchInfoNodeAliasDesc description = strings.history.searchInfoNodeAliasDesc
) )
SearchInfoItem( SearchInfoItem(
title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)" title = strings.history.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
description = strings.searchInfoNodeIdDesc description = strings.history.searchInfoNodeIdDesc
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = strings.searchInfoFooter, // ← "You can paste a full value…" text = strings.history.searchInfoFooter, // ← "You can paste a full value…"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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] val ins = apdu[1]
return when { return when (ins) {
// ── SELECT (INS = 0xA4) ────────────────────────────────────────── // ── SELECT (INS = 0xA4) ──────────────────────────────────────────
ins == 0xA4.toByte() -> handleSelect(apdu) 0xA4.toByte() -> handleSelect(apdu)
// ── READ BINARY (INS = 0xB0) ───────────────────────────────────── // ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
ins == 0xB0.toByte() -> handleReadBinary(apdu) 0xB0.toByte() -> handleReadBinary(apdu)
else -> SW_UNKNOWN else -> SW_UNKNOWN
}.also { Timber.d("APDU OUT: ${it.toHex()}") } }.also { Timber.d("APDU OUT: ${it.toHex()}") }
} }
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
} }
if (rawMessages != null && rawMessages.isNotEmpty()) { if (!rawMessages.isNullOrEmpty()) {
val message = rawMessages[0] as NdefMessage val message = rawMessages[0] as NdefMessage
val uri = extractUri(message) val uri = extractUri(message)
if (uri != null) { if (uri != null) {
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
} }
if (tag != null) { if (tag != null) {
viewModelScope.launch(Dispatchers.IO) { 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