62 Commits

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

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