64 Commits

Author SHA1 Message Date
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
151 changed files with 10471 additions and 2253 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
+26
View File
@@ -0,0 +1,26 @@
{
"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",
"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,149 @@
package com.bitcointxoko.gudariwallet.data
import android.app.Application
import com.bitcointxoko.gudariwallet.data.db.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ContactRepository(app: Application) {
private val dao = AppDatabase.getInstance(app).contactDao()
// --- Observe ---
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
dao.observeAllWithAddresses()
fun observeContact(id: String): Flow<ContactWithAddresses?> =
dao.observeById(id)
// --- Create ---
suspend fun createContact(
localAlias: String,
lnAddress: String? = null,
lnUrl: String? = null,
pictureUrl: String? = null
): ContactEntity {
val contact = ContactEntity(
localAlias = localAlias,
displayName = null,
name = null,
pictureUrl = pictureUrl,
nostrPubkey = null,
nip05 = null,
about = null,
lnAddress = lnAddress
)
dao.insert(contact)
// Also add to payment_addresses table if lnAddress provided
lnAddress?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
address = it,
isDefault = true
)
)
}
lnUrl?.let {
dao.insertAddress(
PaymentAddressEntity(
contactId = contact.id,
type = PaymentAddressType.LNURL.name,
address = it,
isDefault = lnAddress == null // default only if no lnAddress
)
)
}
return contact
}
// --- Update ---
suspend fun updateAlias(id: String, newAlias: String) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
}
suspend fun updatePicture(id: String, pictureUrl: String?) {
val existing = dao.getById(id) ?: return
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
}
// --- Payment addresses ---
suspend fun addAddress(
contactId: String,
type: PaymentAddressType,
address: String,
label: String? = null,
makeDefault: Boolean = false
) {
if (makeDefault) dao.clearDefault(contactId, type.name)
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
dao.getById(contactId)?.let {
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
}
}
dao.insertAddress(
PaymentAddressEntity(
contactId = contactId,
type = type.name,
address = address,
label = label,
isDefault = makeDefault
)
)
}
suspend fun removeAddress(addressId: String) {
dao.deleteAddressById(addressId)
}
// --- Delete ---
suspend fun deleteContact(id: String) {
dao.deleteById(id) // cascades to payment_addresses
}
// --- Lookup (for send flow) ---
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
dao.findByLnAddress(lnAddress)
suspend fun findByLnurl(lnurl: String): ContactEntity? =
dao.findByAddress(lnurl)
// --- Contact-Tx link ---
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
dao.observeContactsForPayment(checkingId)
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
}
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
dao.unlinkPaymentFromContact(checkingId, contactId)
}
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
dao.observePaymentIdsForContact(contactId)
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
dao.observePaymentContactNames()
.map { list -> list.associate { it.checkingId to it.contactName } }
suspend fun findCheckingIdsByContactName(query: String): List<String> =
dao.findCheckingIdsByContactName(query)
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
dao.findCheckingIdsByContactIds(contactIds)
}
@@ -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}")
@@ -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())
@@ -109,9 +109,8 @@ 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")
} }
} }
@@ -160,4 +159,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 queryByCheckingIds(
checkingIds : List<String>,
filter : PaymentFilter
): List<PaymentRecord> {
if (checkingIds.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.getByCheckingIdsFiltered(
checkingIds = checkingIds,
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
minCreatedAt = filter.minCreatedAt,
maxCreatedAt = filter.maxCreatedAt,
statusPattern = statusPattern
).map { it.toDomain() }
}
} }
@@ -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 = 9,
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,108 @@ 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)")
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -93,7 +202,16 @@ 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
)
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -0,0 +1,126 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.*
import kotlinx.coroutines.flow.Flow
data class ContactWithAddresses(
@Embedded val contact: ContactEntity,
@Relation(
parentColumn = "id",
entityColumn = "contactId"
)
val addresses: List<PaymentAddressEntity>
)
@Dao
interface ContactDao {
// --- Contacts ---
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAll(): Flow<List<ContactEntity>>
@Transaction
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
@Transaction
@Query("SELECT * FROM contacts WHERE id = :id")
fun observeById(id: String): Flow<ContactWithAddresses?>
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): ContactEntity?
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
suspend fun findByAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
suspend fun findByLnAddress(address: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(contact: ContactEntity)
@Update
suspend fun update(contact: ContactEntity)
@Query("DELETE FROM contacts WHERE id = :id")
suspend fun deleteById(id: String)
// --- Payment addresses ---
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAddress(address: PaymentAddressEntity)
@Update
suspend fun updateAddress(address: PaymentAddressEntity)
@Query("DELETE FROM payment_addresses WHERE id = :id")
suspend fun deleteAddressById(id: String)
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
// Clear the isDefault flag for a type before setting a new default
@Query("""
UPDATE payment_addresses
SET isDefault = 0
WHERE contactId = :contactId AND type = :type
""")
suspend fun clearDefault(contactId: String, type: String)
// Insert a link
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
// Delete a link
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
// Delete all contact links for a payment
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
suspend fun clearContactsForPayment(checkingId: String)
// Get all contacts linked to a payment
@Query("""
SELECT c.* FROM contacts c
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
WHERE tcl.checkingId = :checkingId
""")
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
// Get all payments linked to a contact
@Query("""
SELECT checkingId FROM tx_contact_links
WHERE contactId = :contactId
ORDER BY createdAt DESC
""")
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
// Returns all (checkingId, displayName) pairs that have a linked contact,
// so HistoryViewModel can build its lookup map in a single reactive query.
@Query("""
SELECT tcl.checkingId AS checkingId,
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
""")
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
// For text search matching contact names — returns checkingIds of matched payments
@Query("""
SELECT tcl.checkingId FROM tx_contact_links tcl
INNER JOIN contacts c ON c.id = tcl.contactId
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
""")
suspend fun findCheckingIdsByContactName(query: String): List<String>
// For filter-by-contact — returns checkingIds for specific contact IDs
@Query("""
SELECT DISTINCT checkingId FROM tx_contact_links
WHERE contactId IN (:contactIds)
""")
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String>
}
@@ -0,0 +1,36 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
@Entity(
tableName = "contacts",
indices = [
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
Index(value = ["lnAddress"])
]
)
data class ContactEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
// --- Display identity ---
val localAlias: String?, // user-set name — highest display priority
val displayName: String?, // future: from nostr kind 0 display_name
val name: String?, // future: from nostr kind 0 name
val pictureUrl: String?, // future: from nostr kind 0 picture
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
val nostrPubkey: String?, // 32-byte hex; unique index
val nip05: String?, // e.g. user@domain.com
val about: String?, // bio / notes
// --- Payment fast-access (denormalized for single-row lookup) ---
val lnAddress: String?, // primary Lightning Address (lud16)
// --- Metadata ---
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,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 checkingId : 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) }
} }
@@ -32,6 +32,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 checkingId IN (:checkingIds)
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 getByCheckingIdsFiltered(
checkingIds : 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,34 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "tx_contact_links",
primaryKeys = ["checkingId", "contactId"],
foreignKeys = [
ForeignKey(
entity = PaymentRecordEntity::class,
parentColumns = ["checkingId"],
childColumns = ["checkingId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ContactEntity::class,
parentColumns = ["id"],
childColumns = ["contactId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("checkingId"),
Index("contactId")
]
)
data class TxContactCrossRef(
val checkingId : String,
val contactId : String,
val role : String? = null,
val createdAt : Long = System.currentTimeMillis()
)
@@ -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)
)
}
}
} }
} }
@@ -92,7 +92,8 @@ sealed class SendState {
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
) : SendState() ) : SendState()
data class Error(val message: String) : SendState() data class Error(val message: String) : SendState()
} }
@@ -57,16 +57,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.ContactRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── 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,
@@ -81,6 +91,11 @@ 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/{checkingId}"
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}"
@Composable @Composable
fun WalletScreen( fun WalletScreen(
@@ -115,11 +130,18 @@ fun WalletScreen(
repo = vm.repo, repo = vm.repo,
aliasRepo = vm.aliasRepo, aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache, paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
fiatCurrency = vm.selectedCurrency, fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit fiatSatsPerUnit = vm.fiatSatsPerUnit
) )
} }
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 +149,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 +222,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 +232,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 +352,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
) )
} }
@@ -415,6 +453,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,
@@ -109,6 +117,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 +142,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)
@@ -62,6 +66,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,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,119 @@
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.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.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,
) {
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
)
}
// ── Save contact affordance (send-side only) ─────────────────────────
if (lnurl != null && onSaveContact != null) {
Spacer(Modifier.height(16.dp))
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,652 @@
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.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailScreen(
viewModel: ContactDetailViewModel,
onBack: () -> Unit,
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) }
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() },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding)
)
}
}
}
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
if (pendingDelete) {
AlertDialog(
onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Delete contact") },
text = { Text("This contact and all their addresses will be permanently removed.") },
confirmButton = {
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelDelete() }) {
Text(strings.cancel)
}
}
)
}
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 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,
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) }
)
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,
) {
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)) {
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)
)
}
}
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
// ── 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 = onSetDefault,
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = if (address.isDefault) Icons.Filled.Star
else Icons.Outlined.Star,
contentDescription = if (address.isDefault) "Default address"
else "Set as default",
modifier = Modifier.size(18.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
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 alias sheet ───────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditAliasSheet(
currentAlias: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
var alias by remember { mutableStateOf(currentAlias) }
val canSave = alias.isNotBlank()
LaunchedEffect(Unit) { focusRequester.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Edit name", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = alias,
onValueChange = { alias = it },
label = { Text("Name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) onSave(alias)
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
val strings = LocalAppStrings.current
TextButton(onClick = onDismiss) { Text(strings.cancel) }
Button(
onClick = { focusManager.clearFocus(); onSave(alias) },
enabled = canSave
) { Text("Save") }
}
}
}
}
@@ -0,0 +1,111 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
sealed class ContactDetailUiState {
data object Loading : ContactDetailUiState()
data object NotFound : ContactDetailUiState()
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
}
class ContactDetailViewModel(
private val contactId: String,
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
.map { contact ->
if (contact == null) ContactDetailUiState.NotFound
else ContactDetailUiState.Success(contact)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactDetailUiState.Loading
)
// ── Delete contact ─────────────────────────────────────────────────────
private val _pendingDelete = MutableStateFlow(false)
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
fun requestDelete() { _pendingDelete.value = true }
fun cancelDelete() { _pendingDelete.value = false }
fun confirmDelete(onDeleted: () -> Unit) {
viewModelScope.launch {
repo.deleteContact(contactId)
_pendingDelete.value = false
onDeleted()
}
}
// ── Edit alias ─────────────────────────────────────────────────────────
fun updateAlias(newAlias: String) {
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
}
// ── Payment addresses ──────────────────────────────────────────────────
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = type,
address = address.trim(),
label = label?.trim()?.takeIf { it.isNotEmpty() },
makeDefault = makeDefault
)
}
}
fun removeAddress(addressId: String) {
viewModelScope.launch { repo.removeAddress(addressId) }
}
fun setDefaultAddress(address: PaymentAddressEntity) {
viewModelScope.launch {
repo.addAddress(
contactId = contactId,
type = PaymentAddressType.valueOf(address.type),
address = address.address,
label = address.label,
makeDefault = true
)
}
}
// ── Add address sheet state ────────────────────────────────────────────
private val _showAddAddressSheet = MutableStateFlow(false)
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
// ── Edit alias sheet state ─────────────────────────────────────────────
private val _showEditAliasSheet = MutableStateFlow(false)
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
class Factory(
private val contactId: String,
private val repo: ContactRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactDetailViewModel(contactId, repo) as T
}
}
@@ -0,0 +1,151 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactPickerSheet(
contacts: List<ContactWithAddresses>,
onContactSelected: (contactId: String) -> Unit,
onCreateNew: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var search by remember { mutableStateOf("") }
val filtered = remember(contacts, search) {
if (search.isBlank()) contacts
else contacts.filter { c ->
val name = c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: ""
name.contains(search, ignoreCase = true)
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
// Search field
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Search") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
keyboardType = KeyboardType.Text,
autoCorrectEnabled = false
),
modifier = Modifier.fillMaxWidth()
)
// Contact list
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(filtered, key = { it.contact.id }) { item ->
val displayName = item.contact.localAlias
?: item.contact.displayName
?: item.contact.name
?: "Unknown"
ListItem(
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = item.addresses.firstOrNull()?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
},
modifier = Modifier.clickable {
onContactSelected(item.contact.id)
}
)
}
if (filtered.isEmpty() && search.isNotBlank()) {
item {
Text(
text = "No contacts match \"$search\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
// Create new contact button
HorizontalDivider()
TextButton(
onClick = onCreateNew,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Create new contact")
}
}
}
}
@@ -0,0 +1,196 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
viewModel: ContactsViewModel,
onContactClick: (contactId: String) -> Unit,
onBack: () -> Unit
) {
val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCreateSheet by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contacts") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.back
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add contact")
}
}
) { innerPadding ->
when (val state = uiState) {
is ContactsUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ContactsUiState.Success -> {
if (state.contacts.isEmpty()) {
ContactsEmptyContent(
onAdd = { showCreateSheet = true },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.contacts, key = { it.contact.id }) { item ->
ContactRow(item, onClick = { onContactClick(item.contact.id) })
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
}
}
}
}
}
}
if (showCreateSheet) {
CreateContactSheet(
onSave = { name, addressType, address ->
viewModel.createContact(name, addressType, address)
showCreateSheet = false
},
onDismiss = { showCreateSheet = false }
)
}
}
// ── Empty state ────────────────────────────────────────────────────────────────
@Composable
private fun ContactsEmptyContent(
onAdd: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
Text(
text = "No contacts yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
Text(
text = "Tap + to add your first contact",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add contact")
}
}
}
// ── Contact row ────────────────────────────────────────────────────────────────
@Composable
private fun ContactRow(
item: ContactWithAddresses,
onClick: () -> Unit
) {
val contact = item.contact
val displayName = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
val primaryAddress = item.addresses
.firstOrNull { it.isDefault }
?: item.addresses.firstOrNull()
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = displayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = primaryAddress?.let { addr ->
{
Text(
text = addr.address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
leadingContent = {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(40.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayName.first().uppercaseChar().toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
)
}
@@ -0,0 +1,58 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
sealed class ContactsUiState {
data object Loading : ContactsUiState()
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
}
class ContactsViewModel(
private val repo: ContactRepository
) : ViewModel() {
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
.map { ContactsUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ContactsUiState.Loading
)
fun createContact(
name: String,
addressType: PaymentAddressType?,
address: String?
) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
repo.createContact(
localAlias = name.trim(),
lnAddress = lnAddress,
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
)
}
}
fun deleteContact(id: String) {
viewModelScope.launch {
repo.deleteContact(id)
}
}
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ContactsViewModel(repo) as T
}
}
@@ -0,0 +1,160 @@
package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateContactSheet(
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
onDismiss: () -> Unit,
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
initialAddress: String? = null
) {
val strings = LocalAppStrings.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
var name by remember { mutableStateOf("") }
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
var address by remember { mutableStateOf(initialAddress ?: "") }
val nameFocus = remember { FocusRequester() }
val canSave = name.isNotBlank()
val addressLabel = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
PaymentAddressType.LNURL -> "LNURL"
else -> "Address"
}
val addressPlaceholder = when (selectedType) {
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
PaymentAddressType.LNURL -> "LNURL1..."
else -> ""
}
LaunchedEffect(Unit) { nameFocus.requestFocus() }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New contact",
style = MaterialTheme.typography.titleMedium
)
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("Contact name") },
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(nameFocus)
)
// Address type toggle
Text(
text = "Payment address (optional)",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
onClick = {
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
address = ""
},
label = { Text("Lightning Address") }
)
FilterChip(
selected = selectedType == PaymentAddressType.LNURL,
onClick = {
selectedType = PaymentAddressType.LNURL
address = ""
},
label = { Text("LNURL") }
)
}
// Address field
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text(addressLabel) },
placeholder = { Text(addressPlaceholder) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (canSave) {
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
}
}
),
modifier = Modifier.fillMaxWidth()
)
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(strings.cancel)
}
Button(
onClick = {
focusManager.clearFocus()
onSave(
name.trim(),
selectedType.takeIf { address.isNotBlank() },
address.trim().takeIf { it.isNotEmpty() }
)
},
enabled = canSave
) {
Text("Save")
}
}
}
}
}
@@ -14,7 +14,6 @@ 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.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -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()
}
}
}
@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
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 ──────────────────────────────────────────────────────────────
@@ -76,6 +77,7 @@ fun HistoryScreen(
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
val availableTypes by vm.availableTypes.collectAsState() val availableTypes by vm.availableTypes.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val contactNames by vm.contactNames.collectAsState()
var filterSheetVisible by rememberSaveable { mutableStateOf(false) } var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
val dismissFilterSheet = { filterSheetVisible = false } val dismissFilterSheet = { filterSheetVisible = false }
@@ -88,12 +90,12 @@ fun HistoryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(strings.historyTitle) }, // ← "History" title = { Text(strings.history.historyTitle) }, // ← "History"
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 // ← "Close"
) )
} }
}, },
@@ -107,7 +109,7 @@ fun HistoryScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = strings.historySearchPayments // ← "Search payments" contentDescription = strings.history.historySearchPayments // ← "Search payments"
) )
} }
} }
@@ -117,7 +119,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 // ← "Filter payments"
) )
} }
} }
@@ -190,7 +192,7 @@ 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) // ← "Retry"
} }
} }
} }
@@ -209,7 +211,7 @@ fun HistoryScreen(
if (s.payments.isEmpty()) { if (s.payments.isEmpty()) {
Text( Text(
text = strings.historyNoPayments, // ← "No payments yet." text = strings.history.historyNoPayments, // ← "No payments yet."
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -233,6 +235,7 @@ fun HistoryScreen(
payment = payment, payment = payment,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
contactName = contactNames[payment.checkingId],
onClick = { onPaymentClick(payment) } onClick = { onPaymentClick(payment) }
) )
HorizontalDivider( HorizontalDivider(
@@ -309,13 +312,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 // ← "Search help"
) )
} }
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions" placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -336,7 +339,7 @@ private fun SearchBar(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ContentPaste, imageVector = Icons.Default.ContentPaste,
contentDescription = strings.historySearchPaste // ← "Paste" contentDescription = strings.history.historySearchPaste // ← "Paste"
) )
} }
IconButton( IconButton(
@@ -345,7 +348,7 @@ private fun SearchBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historySearchClear, // ← "Clear search" contentDescription = strings.history.historySearchClear, // ← "Clear search"
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)
) )
@@ -381,15 +384,15 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (filter.direction) { when (filter.direction) {
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing" DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
else -> strings.filterDirectionIncoming // ← "Incoming" else -> strings.history.filterDirectionIncoming // ← "Incoming"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterClearDirection, // ← "Clear direction filter" contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -402,16 +405,16 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (s) { when (s) {
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"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter" contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -425,7 +428,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, // ← "Remove type filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -434,11 +437,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) // ← "XY sats"
filter.minAmountSat != null -> filter.minAmountSat != null ->
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats" strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
else -> else ->
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats" strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
} }
InputChip( InputChip(
selected = true, selected = true,
@@ -447,7 +450,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, // ← "Clear amount filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -457,19 +460,19 @@ 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 // ← "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"
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
strings.filterDateRange( // ← "XY" strings.history.filterDateRange( // ← "XY"
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)) // ← "From X"
else -> else ->
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X" strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
} }
} }
InputChip( InputChip(
@@ -479,7 +482,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, // ← "Clear date filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -489,11 +492,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))) }, // ← "search: …XXXXXXXX"
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = strings.historyClearSearch, // ← "Clear search" contentDescription = strings.history.historyClearSearch, // ← "Clear search"
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,9 +5,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.ContactRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
@@ -21,6 +25,7 @@ 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 kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HistoryViewModel" private const val TAG = "HistoryViewModel"
@@ -29,6 +34,7 @@ class HistoryViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
val fiatCurrency : StateFlow<String?>, val fiatCurrency : StateFlow<String?>,
val fiatSatsPerUnit : StateFlow<Double?> val fiatSatsPerUnit : StateFlow<Double?>
) : ViewModel() { ) : ViewModel() {
@@ -113,6 +119,7 @@ class HistoryViewModel(
?.payments?.firstOrNull { it.checkingId == checkingId } ?.payments?.firstOrNull { it.checkingId == checkingId }
} }
// ── Room-backed query results ────────────────────────────────────────────── // ── Room-backed query results ──────────────────────────────────────────────
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null) private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
@@ -150,13 +157,100 @@ class HistoryViewModel(
else list.filter { it.extra?.tag in f.types } else list.filter { it.extra?.tag in f.types }
} }
// ── Contact name map ─────────────────────────────────────────────────────
// Maps checkingId → display name for any payment that has a linked contact.
// Rebuilt whenever the underlying tx_contact_links or contacts tables change.
val contactNames: StateFlow<Map<String, String>> =
contactRepo.observeAllPaymentContactNames()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()
)
// Maps contactId → display name (for filter chips)
val contactDisplayNames: StateFlow<Map<String, String>> =
contactRepo.observeAllContacts()
.map { contacts ->
contacts.associate { c ->
c.contact.id to (c.contact.localAlias
?: c.contact.displayName
?: c.contact.name
?: "Unknown")
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyMap()
)
// ── Contact assignment ───────────────────────────────────────────────────
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
contactRepo.observeContactsForPayment(paymentHash)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
contactRepo.observeAllContacts()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun assignContact(paymentHash: String, contactId: String) {
viewModelScope.launch {
contactRepo.linkPaymentToContact(paymentHash, contactId)
}
}
fun unassignContact(paymentHash: String, contactId: String) {
viewModelScope.launch {
contactRepo.unlinkPaymentFromContact(paymentHash, contactId)
}
}
fun saveContactAndAssign(
paymentHash : String,
name : String,
addressType : PaymentAddressType?,
address : String?
) {
viewModelScope.launch {
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
val contact = contactRepo.createContact(
localAlias = name,
lnAddress = lnAddress,
lnUrl = lnUrl
)
contactRepo.linkPaymentToContact(paymentHash, contact.id)
}
}
fun toggleContactFilter(contactId: String) {
_filter.update { f ->
val updated = if (contactId in f.contactIds)
f.contactIds - contactId else f.contactIds + contactId
f.copy(contactIds = updated)
}
}
fun clearContactFilter() {
_filter.update { it.copy(contactIds = emptySet()) }
}
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
init { init {
syncManager.init() syncManager.init()
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 }
.collect { f -> .collect { f ->
@@ -164,11 +258,50 @@ class HistoryViewModel(
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state") Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
_roomResults.value = null _roomResults.value = null
} else { } else {
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " + // ── Room query path ───────────────────────────────────
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " + val baseResults = paymentCache.queryAll(f.searchQuery, f)
"amt=${f.minAmountSat}${f.maxAmountSat} " + .toMutableList()
"date=${f.minCreatedAt}${f.maxCreatedAt}")
_roomResults.value = paymentCache.queryAll(f.searchQuery, f) // ── Supplemental contact search ───────────────────────
// 1. Text query may match contact names not in payment_records
if (f.searchQuery.isNotBlank()) {
val contactCheckingIds = contactRepo
.findCheckingIdsByContactName(f.searchQuery)
if (contactCheckingIds.isNotEmpty()) {
val contactPayments = paymentCache
.queryByCheckingIds(contactCheckingIds, f)
// Merge without duplicates
val existingIds = baseResults.map { it.checkingId }.toSet()
baseResults += contactPayments
.filter { it.checkingId !in existingIds }
}
}
// 2. Explicit contact ID filter
if (f.contactIds.isNotEmpty()) {
val contactCheckingIds = contactRepo
.findCheckingIdsByContactIds(f.contactIds.toList())
if (contactCheckingIds.isNotEmpty()) {
// Fetch ALL payments for these contacts, WITH the full filter applied
val contactFiltered = paymentCache.queryByCheckingIds(
checkingIds = contactCheckingIds,
filter = f
)
_roomResults.value = contactFiltered
.also {
Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds}${it.size} results")
}
return@collect
} else {
// Selected contacts have no payments — return empty
_roomResults.value = emptyList()
return@collect
}
}
_roomResults.value = baseResults.also {
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\"${it.size} results")
}
} }
} }
} }
@@ -187,6 +320,7 @@ class HistoryViewModelFactory(
private val repo : WalletRepository, private val repo : WalletRepository,
private val aliasRepo : NodeAliasRepository, private val aliasRepo : NodeAliasRepository,
private val paymentCache : PaymentCacheRepository, private val paymentCache : PaymentCacheRepository,
private val contactRepo : ContactRepository,
private val fiatCurrency : StateFlow<String?>, private val fiatCurrency : StateFlow<String?>,
private val fiatSatsPerUnit: StateFlow<Double?> private val fiatSatsPerUnit: StateFlow<Double?>
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@@ -196,6 +330,7 @@ class HistoryViewModelFactory(
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
paymentCache = paymentCache, paymentCache = paymentCache,
contactRepo = contactRepo,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit
) as T ) as T
@@ -6,24 +6,29 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -33,8 +38,10 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
@@ -42,14 +49,24 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.common.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
@@ -58,8 +75,8 @@ fun PaymentDetailScreen(
vm : HistoryViewModel, vm : HistoryViewModel,
onBack : () -> Unit onBack : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState() val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
@@ -71,14 +88,31 @@ fun PaymentDetailScreen(
val detail = s.detail.details val detail = s.detail.details
when { when {
detail == null -> payment detail == null -> payment
else -> detail.copy( else -> detail.copy(
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo, memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11 bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
) )
} }
} }
is DetailState.Partial -> s.record is DetailState.Partial -> s.record
else -> payment else -> payment
}
// ── Contact state ─────────────────────────────────────────────────────────
val linkedContacts by remember(payment.checkingId) {
vm.contactsForPayment(payment.checkingId)
}.collectAsStateWithLifecycle()
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
var showContactPicker by remember { mutableStateOf(false) }
var showCreateContact by remember { mutableStateOf(false) }
// Best-effort lnAddress to pre-fill CreateContactSheet:
// prefer extra.comment if it looks like a Lightning Address, else null
val paymentLnAddress = remember(enriched) {
enriched.extra?.comment
?.takeIf { it.contains("@") && !it.contains(" ") }
} }
Scaffold( Scaffold(
@@ -86,15 +120,15 @@ fun PaymentDetailScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment" if (payment.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
) )
} }
} }
@@ -104,8 +138,8 @@ fun PaymentDetailScreen(
when (detailState) { when (detailState) {
is DetailState.Loading -> { is DetailState.Loading -> {
Box( Box(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
} }
@@ -116,7 +150,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,33 +159,78 @@ fun PaymentDetailScreen(
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit fiatSatsPerUnit = fiatSatsPerUnit,
linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(payment.checkingId, contactId)
}
) )
} }
} }
else -> { else -> {
PaymentDetailContent( PaymentDetailContent(
payment = enriched, payment = enriched,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
fiatSatsPerUnit = fiatSatsPerUnit, fiatSatsPerUnit = fiatSatsPerUnit,
modifier = Modifier.padding(padding) linkedContacts = linkedContacts,
onAssignContact = { showContactPicker = true },
onUnassignContact = { contactId ->
vm.unassignContact(payment.checkingId, contactId)
},
modifier = Modifier.padding(padding)
) )
} }
} }
} }
// ── Contact picker sheet ──────────────────────────────────────────────────
if (showContactPicker) {
ContactPickerSheet(
contacts = allContacts,
onContactSelected = { contactId ->
vm.assignContact(payment.checkingId, contactId)
showContactPicker = false
},
onCreateNew = {
showContactPicker = false
showCreateContact = true
},
onDismiss = { showContactPicker = false }
)
}
// ── Create & assign contact sheet ─────────────────────────────────────────
if (showCreateContact) {
val addressType = if (paymentLnAddress != null)
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
CreateContactSheet(
initialAddressType = addressType,
initialAddress = paymentLnAddress,
onSave = { name, type, address ->
vm.saveContactAndAssign(payment.checkingId, name, type, address)
showCreateContact = false
},
onDismiss = { showCreateContact = false }
)
}
} }
// ── Detail content ─────────────────────────────────────────────────────────── // ── Detail content ─────────────────────────────────────────────────────────────
@Composable @Composable
private fun PaymentDetailContent( private fun PaymentDetailContent(
payment : PaymentRecord, payment : PaymentRecord,
fiatCurrency : String?, fiatCurrency : String?,
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
modifier : Modifier = Modifier linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
onAssignContact : () -> Unit,
onUnassignContact : (contactId: String) -> Unit,
modifier : Modifier = Modifier
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val context = LocalContext.current val context = LocalContext.current
@@ -173,45 +252,42 @@ private fun PaymentDetailContent(
} }
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// ── Hero amount ────────────────────────────────────────────────────── // ── Hero amount ────────────────────────────────────────────────────────
item { item {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(24.dp), modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( Icon(
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward, else Icons.Default.ArrowDownward,
contentDescription = null, contentDescription = null,
tint = amountColor, tint = amountColor,
modifier = Modifier.size(36.dp) modifier = Modifier.size(36.dp)
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
AmountWithFiatColumn( AmountWithFiatColumn(
amountSat = payment.amountSat, amountSat = payment.amountSat,
isOutgoing = payment.isOutgoing, isOutgoing = payment.isOutgoing,
amountColor = amountColor, amountColor = amountColor,
fiatLine = heroFiat, fiatLine = heroFiat,
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) )
if (payment.feeSat > 0) { if (payment.feeSat > 0) {
val ppm = feePpm( val ppm = feePpm(
amountMsat = kotlin.math.abs(payment.amountMsat), amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat) feeMsat = kotlin.math.abs(payment.feeMsat)
) )
// Build the optional parts separately so the lambda stays clean
val fiatPart = if (feeFiat != null) " ($feeFiat)" else "" val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
val ppmPart = if (ppm != null) " · $ppm ppm" else "" val ppmPart = if (ppm != null) " · $ppm ppm" else ""
val feeLabel = strings.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,43 +301,118 @@ private fun PaymentDetailContent(
} }
} }
// ── Basic info ─────────────────────────────────────────────────────── // ── Contact section ────────────────────────────────────────────────────
item { item {
DetailSection(title = strings.detailSectionDetails) { // ← "Details" DetailSection(title = "Contact") {
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date" if (linkedContacts.isEmpty()) {
// No contact linked — show assign button
OutlinedButton(
onClick = onAssignContact,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
Text("Assign contact")
}
} else {
// Show each linked contact with a remove button
linkedContacts.forEach { contact ->
val name = contact.localAlias
?: contact.displayName
?: contact.name
?: "Unknown"
DetailRow(
label = "Contact",
value = name,
trailingContent = {
IconButton(
onClick = { onUnassignContact(contact.id) },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove contact",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
// Allow assigning additional contacts
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
thickness = 0.5.dp
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.End
) {
androidx.compose.material3.TextButton(onClick = onAssignContact) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = "Add another",
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
// ── Basic info ─────────────────────────────────────────────────────────
item {
DetailSection(title = strings.history.detailSectionDetails) {
DetailRow( DetailRow(
strings.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,
onCopy = { onCopy = {
scope.launch { scope.launch {
clipboard.setClipEntry( clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash)) ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
@@ -271,11 +422,11 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = strings.detailRowPreimage, // ← "Preimage" label = strings.history.detailRowPreimage,
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
onCopy = { onCopy = {
scope.launch { scope.launch {
clipboard.setClipEntry( clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage)) ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
@@ -288,7 +439,7 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = strings.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,
@@ -296,34 +447,18 @@ private fun PaymentDetailContent(
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,
maxLines = 3, maxLines = 3,
onCopy = { onCopy = {
scope.launch { scope.launch {
clipboard.setClipEntry( clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11)) ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
@@ -14,7 +14,8 @@ data class PaymentFilter(
val maxAmountSat : Long? = null, val maxAmountSat : Long? = null,
val minCreatedAt : Long? = null, val minCreatedAt : Long? = null,
val maxCreatedAt : Long? = null, val maxCreatedAt : Long? = null,
val datePreset : DatePreset? = null val datePreset : DatePreset? = null,
val contactIds : Set<String> = emptySet()
) )
enum class DirectionFilter { ALL, OUTGOING, INCOMING } enum class DirectionFilter { ALL, OUTGOING, INCOMING }
@@ -29,12 +30,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> {
@@ -24,14 +24,19 @@ 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.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
import com.bitcointxoko.gudariwallet.util.formatTimestamp
import com.bitcointxoko.gudariwallet.util.isFailedStatus
import com.bitcointxoko.gudariwallet.util.isPendingStatus
@Composable @Composable
internal fun PaymentRow( internal fun PaymentRow(
payment : PaymentRecord, payment : PaymentRecord,
fiatCurrency : String?, fiatCurrency : String?,
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
contactName : String? = null,
onClick : () -> Unit onClick : () -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -57,8 +62,8 @@ internal fun PaymentRow(
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent" contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
else strings.paymentRowReceivedCD, // ← "Received" else strings.history.paymentRowReceivedCD, // ← "Received"
tint = amountColor, tint = amountColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
@@ -66,15 +71,16 @@ internal fun PaymentRow(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } text = contactName // ← contact name first
?: strings.paymentRowNoMemo, // ← "No memo" ?: payment.memo?.takeIf { it.isNotBlank() }
?: strings.history.paymentRowNoMemo,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) { val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time) formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
} }
Text( Text(
text = formattedTime, text = formattedTime,
@@ -82,8 +88,8 @@ internal fun PaymentRow(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
val statusLabel = when { val statusLabel = when {
isPending -> strings.paymentRowPending isPending -> strings.history.paymentRowPending
isFailed -> strings.paymentRowFailed isFailed -> strings.history.paymentRowFailed
else -> null else -> null
} }
val statusColor = when { val statusColor = when {
@@ -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) }
}
}
@@ -0,0 +1,354 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.data.toDomain
import com.bitcointxoko.gudariwallet.data.toGetResponse
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
private const val TAG = "NwcViewModel"
// ── UI State ──────────────────────────────────────────────────────────────────
sealed interface NwcUiState {
data object Loading : NwcUiState
data class Success(
val connections: List<NwcGetResponse>,
val isRefreshingLastUsed: Boolean = false // true only while network sync is in-flight
) : NwcUiState
data class Error(val message: String) : NwcUiState
}
sealed interface ConnectionDetailState {
data object Loading : ConnectionDetailState
data class Success(
val connection : ConnectionData,
val budgets : List<BudgetData>
) : ConnectionDetailState
data class Error(val message: String) : ConnectionDetailState
}
// ── ViewModel ─────────────────────────────────────────────────────────────────
class NwcViewModel(
private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
val uiState: StateFlow<NwcUiState> = _uiState.asStateFlow()
/** Whether a delete operation is currently in flight (disables buttons). */
private val _isDeleting = MutableStateFlow(false)
val isDeleting: StateFlow<Boolean> = _isDeleting.asStateFlow()
// Holds the keypair only while the "add connection" flow is active.
// Cleared as soon as the user dismisses the QR screen.
private val _pendingKeypair = MutableStateFlow<NwcKeypair?>(null)
val pendingKeypair: StateFlow<NwcKeypair?> = _pendingKeypair.asStateFlow()
/** True while a createConnection call is in flight. */
private val _isCreating = MutableStateFlow(false)
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
/** Holds the pairing URL after a successful registration, until dismissed. */
private val _pairingUrl = MutableStateFlow<String?>(null)
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
private val _connectionDetailState =
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
val connectionDetailState: StateFlow<ConnectionDetailState> =
_connectionDetailState.asStateFlow()
private var detailObserverJob: Job? = null
private var cacheObserverJob: Job? = null
// ── Sheet / dialog UI state (survives config change) ────────────────────────
private var isSyncing = false
private val _showAddSheet = MutableStateFlow(false)
val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow()
private val _pendingDeletePubkey = MutableStateFlow<String?>(null)
val pendingDeletePubkey: StateFlow<String?> = _pendingDeletePubkey.asStateFlow()
fun openAddSheet() { _showAddSheet.value = true }
fun closeAddSheet() { _showAddSheet.value = false }
fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey }
fun cancelDelete() { _pendingDeletePubkey.value = null }
// confirmDelete() calls the existing deleteConnection() then clears the pending key
fun confirmDelete(strings: AppStrings) {
_pendingDeletePubkey.value?.let { deleteConnection(it, strings) }
_pendingDeletePubkey.value = null
}
// ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false, strings: AppStrings) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
isSyncing = true
// 1. Subscribe to cache — emits immediately if rows exist, stays alive
// for the ViewModel lifetime. Every cache write re-emits automatically.
if (cacheObserverJob == null) {
// Fast path: one-shot read populates UI before the Flow cold-starts
viewModelScope.launch {
val snapshot = cache.getKeysOnce()
if (snapshot.isNotEmpty() && _uiState.value !is NwcUiState.Success) {
Timber.d("[$TAG] cache snapshot: ${snapshot.size} connection(s)")
_uiState.value = NwcUiState.Success(
connections = snapshot,
isRefreshingLastUsed = true
)
}
}
cacheObserverJob = cache.observeKeys()
.drop(1) // skip first emission — snapshot already handled it
.onEach { cached ->
if (cached.isNotEmpty()) {
Timber.d("[$TAG] cache hit: ${cached.size} connection(s)")
_uiState.value = NwcUiState.Success(
connections = cached.map { it.toDomain().toGetResponse() },
isRefreshingLastUsed = isSyncing // ← reflects current sync state, not always true
)
} else if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Loading
}
}
.launchIn(viewModelScope)
}
// 2. Fetch from network; result is written to cache which re-emits above
viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys ->
Timber.i("[$TAG] network: ${keys.size} NWC connection(s)")
isSyncing = false
cache.syncKeys(keys) // Room write → Flow re-emits
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to load NWC connections")
// Only show error if we have nothing cached to show
isSyncing = false
if (_uiState.value !is NwcUiState.Success) {
_uiState.value = NwcUiState.Error(
e.message ?: strings.nwc.failedToLoadConnections
)
} else {
Timber.w("[$TAG] network failed but cache is showing — non-fatal")
}
}
}
}
/**
* Generates a keypair, registers it with the server, then fetches
* the nostr+walletconnect:// pairing URL.
* Private key is held only in local scope never stored.
*/
fun createConnection(
description : String,
permissions : List<String>,
expiresAt : Long = 0L,
budgets : List<NwcNewBudget> = emptyList(),
strings : AppStrings
) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
viewModelScope.launch {
_isCreating.value = true
try {
runCatching {
val keypair = NwcKeyGenerator.generate()
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}")
check(keypair.pubKeyHex.length == 64) {
"pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}"
}
check(keypair.privKeyHex.length == 64) {
"privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}"
}
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
val registered = repo.registerNwcKey(
pubkey = keypair.pubKeyHex,
request = NwcRegistrationRequest(
description = description,
permissions = permissions,
expires_at = expiresAt,
budgets = budgets
)
)
Timber.i("[$TAG] registered key ✓")
cache.saveKey(registered)
Timber.d("[$TAG] new key cached ✓")
val url = repo.getNwcPairingUrl(keypair.privKeyHex)
Timber.i("[$TAG] pairing URL fetched ✓")
Timber.d("[$TAG] URI=$url")
url
}
.onSuccess { url ->
_pairingUrl.value = url
silentRefresh()
}
.onFailure { e ->
Timber.e(e, "[$TAG] createConnection failed")
_uiState.value = NwcUiState.Error(e.message ?: strings.nwc.failedToCreateConnection)
}
} finally {
_isCreating.value = false
}
}
}
/** Wipes the pairing URL from memory once the user has copied it. */
fun clearPairingUrl() {
Timber.d("[$TAG] clearing pairing URL from memory")
_pairingUrl.value = null
}
// ── Delete / Revoke ──────────────────────────────────────────────────────────
/**
* Deletes an NWC key by pubkey. Used by both the list screen (swipe-to-delete)
* and the detail screen (revoke button). Sets [_isDeleting] for the duration
* so the list screen can disable row interactions while in flight.
*/
fun deleteConnection(pubkey: String, strings: AppStrings) {
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}")
viewModelScope.launch {
_isDeleting.value = true
try {
runCatching { repo.deleteNwcKey(pubkey) }
.onSuccess {
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
cache.deleteKey(pubkey)
val current = _uiState.value
if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success(
current.connections.filter { it.data.pubkey != pubkey }
)
}
silentRefresh()
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
_uiState.value = NwcUiState.Error(
e.message ?: strings.nwc.failedToDeleteConnection
)
}
} finally {
_isDeleting.value = false
}
}
}
fun loadConnectionDetail(pubkey: String, strings: AppStrings) {
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}")
// Cancel any subscription from a previous key — prevents stale Flow
// from a previously viewed key overwriting the current detail state
detailObserverJob?.cancel()
_connectionDetailState.value = ConnectionDetailState.Loading
detailObserverJob = combine(
cache.observeKey(pubkey),
cache.observeBudgets(pubkey)
) { keyEntity, budgetEntities ->
Pair(keyEntity, budgetEntities)
}
.onEach { (keyEntity, budgetEntities) ->
if (keyEntity != null) {
Timber.d("[$TAG] detail cache hit for ${pubkey.take(16)}")
_connectionDetailState.value = ConnectionDetailState.Success(
connection = keyEntity.toDomain().toConnectionData(),
budgets = budgetEntities.map { it.toDomain().toBudgetData() }
)
} else if (_connectionDetailState.value !is ConnectionDetailState.Success) {
_connectionDetailState.value = ConnectionDetailState.Loading
}
}
.launchIn(viewModelScope) // ← now tracked and cancellable
viewModelScope.launch {
runCatching {
repo.getNwcKey(pubkey = pubkey, includeExpired = true, refreshLastUsed = false)
}
.onSuccess { response ->
Timber.i("[$TAG] loaded connection detail ✓")
cache.refreshLastUsed(pubkey, response.data.last_used)
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to load connection detail")
if (_connectionDetailState.value !is ConnectionDetailState.Success) {
_connectionDetailState.value = ConnectionDetailState.Error(
e.message ?: strings.nwc.failedToLoadConnection
)
} else {
Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)")
}
}
}
}
/** Reset to Loading so the next open always shows a fresh load. */
fun clearConnectionDetail() {
Timber.d("[$TAG] clearConnectionDetail")
detailObserverJob?.cancel() // ← stop the old key's Flow immediately
detailObserverJob = null
_connectionDetailState.value = ConnectionDetailState.Loading
}
// ── Silent refresh (no Loading flash) ───────────────────────────────────────
/**
* Syncs the connection list from the server without transitioning to
* [NwcUiState.Loading] first. Used after mutations (create, delete) where
* the UI already shows the correct optimistic state.
* Failures are non-fatal the optimistic state is already correct.
*/
private fun silentRefresh(includeExpired: Boolean = false) {
viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys ->
Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)")
cache.syncKeys(keys) // Room write → cache Flow re-emits to UI
}
.onFailure { e ->
Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)")
}
}
}
// ── Factory ───────────────────────────────────────────────────────────────
class Factory(
private val repo: WalletRepository,
private val cache: NwcCacheRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NwcViewModel(repo, cache) as T
}
}
@@ -0,0 +1,85 @@
package com.bitcointxoko.gudariwallet.ui.onboarding
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
@Composable
fun BiometricLockPage(
appLockEnabled: Boolean,
onAppLockChanged: (Boolean) -> Unit
) {
val strings = LocalAppStrings.current.lock
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Fingerprint,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = strings.appLockTitle,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = strings.appLockDescription,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = strings.appLockToggleLabel,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = if (appLockEnabled) strings.appLockToggleEnabled
else strings.appLockToggleDisabled,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = appLockEnabled,
onCheckedChange = onAppLockChanged
)
}
}
}
}
@@ -265,7 +265,7 @@ fun NotificationsPage(
imageVector = Icons.Default.Notifications, imageVector = Icons.Default.Notifications,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(72.dp), modifier = Modifier.size(72.dp),
// Mirror the BatteryPage tint behaviour // Mirror the BatteryPage tint behavior
tint = if (alreadyGranted) tint = if (alreadyGranted)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else else
@@ -300,7 +300,7 @@ fun NotificationsPage(
} }
} }
// ── Page 5: Battery optimisation ────────────────────────────────────────────── // ── Page 5: Battery optimization ──────────────────────────────────────────────
@Composable @Composable
fun BatteryPage(strings: AppStrings) { fun BatteryPage(strings: AppStrings) {
val context = LocalContext.current val context = LocalContext.current
@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.work.WorkInfo
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.data.AppLockStore
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
private const val PAGE_WELCOME = 0 private const val PAGE_WELCOME = 0
private const val PAGE_SERVER_URL = 1 private const val PAGE_SERVER_URL = 1
@@ -26,13 +26,15 @@ private const val PAGE_INVOICE = 2
private const val PAGE_ADMIN = 3 private const val PAGE_ADMIN = 3
private const val PAGE_NOTIFICATIONS = 4 private const val PAGE_NOTIFICATIONS = 4
private const val PAGE_BATTERY = 5 private const val PAGE_BATTERY = 5
private const val PAGE_SYNC = 6 // ← new private const val PAGE_BIOMETRIC = 6
private const val PAGE_COUNT = 7 // ← was 6 private const val PAGE_SYNC = 7
private const val PAGE_COUNT = 8
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
secretStore : SecretStore, secretStore : SecretStore,
syncStore : HistoricalSyncStore, // ← new syncStore : HistoricalSyncStore,
appLockStore : AppLockStore,
onRequestNotificationPermission: () -> Unit, onRequestNotificationPermission: () -> Unit,
onComplete : () -> Unit onComplete : () -> Unit
) { ) {
@@ -45,12 +47,11 @@ fun OnboardingScreen(
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) } var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var credentialsSaved by remember { mutableStateOf(false) } var appLockEnabled by remember { mutableStateOf(true) }
// ── Sync state ──────────────────────────────────────────────────────────── // ── Sync state ────────────────────────────────────────────────────────────
val vm: OnboardingViewModel = viewModel( val vm: OnboardingViewModel = viewModel(
factory = OnboardingViewModelFactory( factory = OnboardingViewModelFactory(
// LocalContext.current.applicationContext cast to Application
app = androidx.compose.ui.platform.LocalContext.current app = androidx.compose.ui.platform.LocalContext.current
.applicationContext as android.app.Application, .applicationContext as android.app.Application,
secretStore = secretStore, secretStore = secretStore,
@@ -64,31 +65,33 @@ fun OnboardingScreen(
LaunchedEffect(pagerState.currentPage) { error = null } LaunchedEffect(pagerState.currentPage) { error = null }
var previousPage by remember { mutableIntStateOf(0) } val hexKeyRegex = Regex("^[0-9a-f]{32}$")
LaunchedEffect(pagerState.currentPage) {
val current = pagerState.currentPage
if (current > previousPage) {
// User moved forward — save whatever the previous page owned
when (previousPage) {
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
else -> Unit
}
}
previousPage = current
}
fun validateCurrentPage(): Boolean { fun validateCurrentPage(): Boolean {
error = when (pagerState.currentPage) { error = when (pagerState.currentPage) {
PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null PAGE_SERVER_URL -> when {
PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null baseUrl.isBlank() -> strings.onboarding.fieldsRequired
PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null else -> null
else -> null }
PAGE_INVOICE -> when {
invoiceKey.isBlank() -> strings.onboarding.fieldsRequired
!hexKeyRegex.matches(invoiceKey) -> strings.onboarding.invalidKeyFormat
else -> null
}
PAGE_ADMIN -> when {
adminKey.isBlank() -> strings.onboarding.fieldsRequired
!hexKeyRegex.matches(adminKey) -> strings.onboarding.invalidKeyFormat
else -> null
}
else -> null
} }
return error == null return error == null
} }
Scaffold { innerPadding -> Scaffold { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -119,6 +122,10 @@ fun OnboardingScreen(
onRequestNotificationPermission = onRequestNotificationPermission onRequestNotificationPermission = onRequestNotificationPermission
) )
PAGE_BATTERY -> BatteryPage(strings) PAGE_BATTERY -> BatteryPage(strings)
PAGE_BIOMETRIC -> BiometricLockPage(
appLockEnabled = appLockEnabled,
onAppLockChanged = { appLockEnabled = it }
)
PAGE_SYNC -> SyncHistoryPage( PAGE_SYNC -> SyncHistoryPage(
workInfo = syncWorkInfo, workInfo = syncWorkInfo,
isAlreadyDone = syncAlreadyDone, isAlreadyDone = syncAlreadyDone,
@@ -127,7 +134,7 @@ fun OnboardingScreen(
onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) }, onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) },
onContinue = { onContinue = {
scope.launch { scope.launch {
secretStore.setOnboarded() // moved here from PAGE_BATTERY secretStore.setOnboarded()
onComplete() onComplete()
} }
}, },
@@ -167,17 +174,28 @@ fun OnboardingScreen(
Spacer(Modifier.width(1.dp)) Spacer(Modifier.width(1.dp))
} }
val isLastPage = pagerState.currentPage == PAGE_BATTERY // val isLastPage = pagerState.currentPage == PAGE_BATTERY
Button(onClick = { Button(onClick = {
if (!validateCurrentPage()) return@Button if (!validateCurrentPage()) return@Button
scope.launch { scope.launch {
// Save each field as soon as the user advances past it
when (pagerState.currentPage) { when (pagerState.currentPage) {
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl) PAGE_SERVER_URL -> {
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey) Timber.d("ONBOARDING saving baseUrl (len=${baseUrl.length}): $baseUrl")
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey) secretStore.saveBaseUrl(baseUrl)
else -> Unit }
PAGE_INVOICE -> {
Timber.d("ONBOARDING saving invoiceKey (len=${invoiceKey.length}): $invoiceKey")
secretStore.saveInvoiceKey(invoiceKey)
}
PAGE_ADMIN -> {
Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey")
secretStore.saveAdminKey(adminKey)
}
PAGE_BIOMETRIC -> {
scope.launch { appLockStore.setAppLockEnabled(appLockEnabled) }
}
else -> Unit
} }
pagerState.animateScrollToPage(pagerState.currentPage + 1) pagerState.animateScrollToPage(pagerState.currentPage + 1)
} }
@@ -25,7 +25,7 @@ internal fun AmountDisplay(
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
Text( Text(
text = strings.amountDisplaySats(amountSats), // ← "1 sat" or "X sats" text = strings.receive.amountDisplaySats(amountSats), // ← "1 sat" or "X sats"
style = style, style = style,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -1,29 +0,0 @@
package com.bitcointxoko.gudariwallet.ui.receive
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessHigh
import androidx.compose.material.icons.filled.BrightnessLow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.bitcointxoko.gudariwallet.LocalAppStrings
@Composable
internal fun BrightnessToggleButton(
isOn : Boolean,
onToggle: () -> Unit
) {
val strings = LocalAppStrings.current
IconButton(onClick = onToggle) {
Icon(
imageVector = if (isOn) Icons.Filled.BrightnessHigh
else Icons.Filled.BrightnessLow,
contentDescription = if (isOn) strings.brightnessReduce // ← "Reduce brightness"
else strings.brightnessBoost, // ← "Boost brightness"
tint = if (isOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -1,7 +1,5 @@
package com.bitcointxoko.gudariwallet.ui.receive package com.bitcointxoko.gudariwallet.ui.receive
import android.app.Activity
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -58,15 +56,16 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker import com.bitcointxoko.gudariwallet.ui.common.PaymentSuccessContent
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.util.buildUpTaps import com.bitcointxoko.gudariwallet.util.buildUpTaps
import com.bitcointxoko.gudariwallet.util.doubleTap
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import com.bitcointxoko.gudariwallet.util.heavyClick
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun ReceiveScreen( fun ReceiveScreen(
@@ -74,32 +73,17 @@ fun ReceiveScreen(
nfcVm : NfcViewModel, nfcVm : NfcViewModel,
onNavigateToPaymentDetail: (checkingId: String) -> Unit onNavigateToPaymentDetail: (checkingId: String) -> Unit
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val context = LocalContext.current
val brightness = rememberBrightnessController()
var showAddress by remember { mutableStateOf(false) }
val receiveState by vm.receiveState.collectAsStateWithLifecycle() val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle() val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle() val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle() val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
val context = LocalContext.current
val window = (context as Activity).window
val originalBrightness = remember { window.attributes.screenBrightness }
var brightnessOn by remember { mutableStateOf(false) }
var showAddress by remember { mutableStateOf(false) }
val onToggleBrightness = {
val lp = window.attributes
lp.screenBrightness = if (!brightnessOn)
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
else originalBrightness
window.attributes = lp
brightnessOn = !brightnessOn
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val lp = window.attributes
lp.screenBrightness = originalBrightness
window.attributes = lp
nfcVm.stopEmulating() nfcVm.stopEmulating()
vm.resetReceiveState() vm.resetReceiveState()
} }
@@ -141,14 +125,14 @@ fun ReceiveScreen(
lightningAddress = lightningAddress, lightningAddress = lightningAddress,
showAddress = showAddress, showAddress = showAddress,
onToggleAddress = { showAddress = !showAddress }, onToggleAddress = { showAddress = !showAddress },
brightnessOn = brightnessOn, brightnessOn = brightness.isOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = brightness.toggle,
isLoading = receiveState is ReceiveState.AwaitingInvoice, isLoading = receiveState is ReceiveState.AwaitingInvoice,
onCreateInvoice = { amountSats, memo -> onCreateInvoice = { amountSats, memo ->
vm.createInvoice( vm.createInvoice(
amountSats, amountSats,
memo, memo,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice
) )
} }
) )
@@ -163,9 +147,9 @@ fun ReceiveScreen(
callback = state.callback, callback = state.callback,
amountSats = amountSats, amountSats = amountSats,
memo = state.defaultDescription, memo = state.defaultDescription,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice, failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice,
withdrawRejected = strings.receiveVmWithdrawRejected, withdrawRejected = strings.receive.receiveVmWithdrawRejected,
withdrawFailed = strings.receiveVmWithdrawFailed, withdrawFailed = strings.receive.receiveVmWithdrawFailed,
) )
}, },
onDismiss = { vm.resetReceiveState() } onDismiss = { vm.resetReceiveState() }
@@ -173,16 +157,21 @@ fun ReceiveScreen(
} }
is ReceiveState.PaymentReceived -> { is ReceiveState.PaymentReceived -> {
ReceiveSuccessContent( PaymentSuccessContent(
amountSats = state.amountSats, title = strings.receive.paymentReceived,
memo = state.memo, amountSats = state.amountSats,
fiatRate = fiatRate, fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
fiatCurrency = fiatCurrency, subLine = state.memo?.takeIf { it.isNotBlank() },
onDone = { vm.resetReceiveState() }, onDetails = { onNavigateToPaymentDetail(state.checkingId) },
onDetails = { onNavigateToPaymentDetail(state.checkingId) } secondaryLabel = strings.done,
onSecondary = { vm.resetReceiveState() },
lnurl = null, // sender identity not available
onSaveContact = null,
) )
} }
is ReceiveState.Error -> { is ReceiveState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column( Column(
@@ -190,7 +179,7 @@ fun ReceiveScreen(
modifier = Modifier.padding(24.dp) modifier = Modifier.padding(24.dp)
) { ) {
Text( Text(
text = strings.receiveError, // ← "Error" text = strings.receive.receiveError, // ← "Error"
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -216,8 +205,8 @@ fun ReceiveScreen(
state = receiveState as ReceiveState.InvoiceReady, state = receiveState as ReceiveState.InvoiceReady,
fiatRate = fiatRate, fiatRate = fiatRate,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn, brightnessOn = brightness.isOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = brightness.toggle,
nfcVm = nfcVm, nfcVm = nfcVm,
onReset = { vm.resetReceiveState() } onReset = { vm.resetReceiveState() }
) )
@@ -243,8 +232,8 @@ private fun LnurlWithdrawSheet(
val parsed = amountText.trim().toLongOrNull() val parsed = amountText.trim().toLongOrNull()
return when { return when {
parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount" parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount"
parsed < state.minSats -> { amountError = strings.minimumSats(state.minSats); null } // ← "Minimum is X sats" parsed < state.minSats -> { amountError = strings.receive.minimumSats(state.minSats); null } // ← "Minimum is X sats"
parsed > state.maxSats -> { amountError = strings.maximumSats(state.maxSats); null } // ← "Maximum is X sats" parsed > state.maxSats -> { amountError = strings.receive.maximumSats(state.maxSats); null } // ← "Maximum is X sats"
else -> { amountError = null; parsed } else -> { amountError = null; parsed }
} }
} }
@@ -265,7 +254,7 @@ private fun LnurlWithdrawSheet(
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
Column { Column {
Text( Text(
text = strings.withdrawFrom, // ← "Withdraw from" text = strings.receive.withdrawFrom, // ← "Withdraw from"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -304,7 +293,7 @@ private fun LnurlWithdrawSheet(
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
) )
Text( Text(
text = strings.withdrawButtonAmount(state.maxSats), // ← "X sats" text = strings.receive.withdrawButtonAmount(state.maxSats), // ← "X sats"
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer color = MaterialTheme.colorScheme.onSecondaryContainer
@@ -315,12 +304,12 @@ private fun LnurlWithdrawSheet(
OutlinedTextField( OutlinedTextField(
value = amountText, value = amountText,
onValueChange = { amountText = it; amountError = null }, onValueChange = { amountText = it; amountError = null },
label = { Text(strings.amountInSats) }, // ← "Amount (sats)" label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
supportingText = { supportingText = {
if (amountError != null) if (amountError != null)
Text(amountError!!, color = MaterialTheme.colorScheme.error) Text(amountError!!, color = MaterialTheme.colorScheme.error)
else else
Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X Y sats" Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
}, },
isError = amountError != null, isError = amountError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -339,8 +328,8 @@ private fun LnurlWithdrawSheet(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (fixedAmount) strings.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats" if (fixedAmount) strings.receive.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats"
else strings.withdrawButton // ← "Withdraw" else strings.receive.withdrawButton // ← "Withdraw"
) )
} }
@@ -397,7 +386,7 @@ private fun ReceiveIdleContent(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = strings.receive, // ← "Receive" text = strings.receive.receive, // ← "Receive"
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
if (lightningAddress != null) { if (lightningAddress != null) {
@@ -438,12 +427,12 @@ private fun ReceiveIdleContent(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
QrDisplayCard( QrDisplayCard(
content = lightningAddress, content = lightningAddress,
contentDescription = strings.lightningAddressQrCode, // ← "Lightning address QR code" contentDescription = strings.receive.lightningAddressQrCode, // ← "Lightning address QR code"
clipLabel = strings.lightningAddressLabel, // ← "Lightning Address" clipLabel = strings.receive.lightningAddressLabel, // ← "Lightning Address"
shareTitle = strings.shareLightningAddress, // ← "Share Lightning Address" shareTitle = strings.receive.shareLightningAddress, // ← "Share Lightning Address"
textToCopy = lightningAddress, textToCopy = lightningAddress,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness onToggleBrightness = onToggleBrightness
) )
} }
@@ -512,7 +501,7 @@ private fun AmountStepContent(
val number = amountText.trim().toDoubleOrNull() ?: return@remember null val number = amountText.trim().toDoubleOrNull() ?: return@remember null
when (activeUnit) { when (activeUnit) {
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency) AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
AmountUnit.FIAT -> "${(number * fiatRate!!).toLong()} sats" AmountUnit.FIAT -> "${(number * fiatRate).toLong()} sats"
} }
} }
@@ -560,7 +549,7 @@ private fun AmountStepContent(
) )
UnitWheelPicker( UnitWheelPicker(
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency!!) else listOf(strings.sats), units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1, selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
onIndexSelected = { newIndex -> onIndexSelected = { newIndex ->
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
@@ -605,8 +594,8 @@ private fun MemoStepContent(
OutlinedTextField( OutlinedTextField(
value = memo, value = memo,
onValueChange = { memo = it }, onValueChange = { memo = it },
label = { Text(strings.memoOptional) }, // ← "Memo (optional)" label = { Text(strings.receive.memoOptional) }, // ← "Memo (optional)"
placeholder = { Text(strings.memoPlaceholder) }, // ← "What's this for?" placeholder = { Text(strings.receive.memoPlaceholder) }, // ← "What's this for?"
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -621,8 +610,8 @@ private fun MemoStepContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (isLoading) strings.creatingInvoice // ← "Creating invoice…" if (isLoading) strings.receive.creatingInvoice // ← "Creating invoice…"
else strings.createInvoice // ← "Create Invoice" else strings.receive.createInvoice // ← "Create Invoice"
) )
} }
@@ -654,7 +643,7 @@ private fun ReceiveInvoiceContent(
} }
LaunchedEffect(state.expiresAt) { LaunchedEffect(state.expiresAt) {
while (secondsLeft > 0L) { while (secondsLeft > 0L) {
delay(1_000L) delay(1_000L.milliseconds)
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L) secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
.coerceAtLeast(0L) .coerceAtLeast(0L)
} }
@@ -682,23 +671,23 @@ private fun ReceiveInvoiceContent(
} }
QrDisplayCard( QrDisplayCard(
content = state.bolt11.uppercase(), content = state.bolt11.uppercase(),
contentDescription = strings.lightningInvoiceQrCode, // ← "Lightning invoice QR code" contentDescription = strings.receive.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
clipLabel = strings.invoiceLabel, // ← "Invoice" clipLabel = strings.receive.invoiceLabel, // ← "Invoice"
shareTitle = strings.shareInvoice, // ← "Share Invoice" shareTitle = strings.receive.shareInvoice, // ← "Share Invoice"
textToCopy = state.bolt11, textToCopy = state.bolt11,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = onToggleBrightness,
isNfcEmulating = isEmulating, isNfcEmulating = isEmulating,
onToggleNfc = { onToggleNfc = {
if (isEmulating) nfcVm.stopEmulating() if (isEmulating) nfcVm.stopEmulating()
else nfcVm.startEmulating("lightning:${state.bolt11}") else nfcVm.startEmulating("lightning:${state.bolt11}")
}, },
nfcEnabled = !isExpired, nfcEnabled = !isExpired,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
qrModifier = Modifier qrModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f)
) )
@@ -711,7 +700,7 @@ private fun ReceiveInvoiceContent(
if (!isExpired) { if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text( Text(
text = strings.waiting, // ← "Waiting…" text = strings.receive.waiting, // ← "Waiting…"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -722,7 +711,7 @@ private fun ReceiveInvoiceContent(
) )
} else { } else {
Text( Text(
text = strings.invoiceExpired, // ← "Invoice expired" text = strings.receive.invoiceExpired, // ← "Invoice expired"
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
@@ -734,56 +723,9 @@ private fun ReceiveInvoiceContent(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
if (isExpired) strings.newInvoice // ← "New Invoice" if (isExpired) strings.receive.newInvoice // ← "New Invoice"
else strings.cancel // ← "Cancel" else strings.cancel // ← "Cancel"
) )
} }
} }
} }
// ── ReceiveSuccessContent ─────────────────────────────────────────────────────
@Composable
private fun ReceiveSuccessContent(
amountSats : Long,
memo : String?,
fiatRate : Double?,
fiatCurrency : String?,
onDone : () -> Unit,
onDetails : () -> Unit
) {
val strings = LocalAppStrings.current
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(16.dp))
Text(
text = strings.paymentReceived, // ← "Payment Received!"
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(memo, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(32.dp))
Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) {
Text(strings.details) // ← "Details"
}
Spacer(Modifier.height(32.dp))
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text(strings.done) // ← "Done"
}
}
}

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