Compare commits
64 Commits
0.1.0
..
2e11cecbeb
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e11cecbeb | |||
| e57414de17 | |||
| 0aa55710a9 | |||
| ef03a986a4 | |||
| 2bec9f3908 | |||
| f9991622b3 | |||
| f931405c90 | |||
| c3490808c0 | |||
| 4546844ea9 | |||
| 181cf56239 | |||
| 262d5f068d | |||
| 201242c797 | |||
| 8c84eca8eb | |||
| 4e22f4d3e9 | |||
| c155f43870 | |||
| f2d6aeb13b | |||
| 91dc24d1e1 | |||
| 321c3d9c55 | |||
| 4756681a2a | |||
| cba4a740a0 | |||
| 8baa9ad398 | |||
| bd0c8e477b | |||
| 25855ad7b3 | |||
| 3c834efb20 | |||
| 2edd30ac21 | |||
| 8e20959bbb | |||
| 16e955e553 | |||
| 4694b6d44e | |||
| aaadd0d73f | |||
| 98bc466edd | |||
| 23e92ea8db | |||
| cad54c69e7 | |||
| 25d13b5a0b | |||
| 868869fd88 | |||
| b053da6a05 | |||
| 78eb034327 | |||
| a2754a7168 | |||
| 787acff1e8 | |||
| 39c31f96a8 | |||
| e242c7b5d0 | |||
| d1c3722781 | |||
| 3c9b480a9e | |||
| b90fdca83a | |||
| 8405f33c47 | |||
| e71a0ffdb9 | |||
| 2bc92fca06 | |||
| a121d40bbd | |||
| e5f8156ecf | |||
| 9ba408e157 | |||
| f9ddebac8a | |||
| 8be9d8ea43 | |||
| af59b385e6 | |||
| a22e5e4628 | |||
| 28b366d81f | |||
| 08a76e00d9 | |||
| 237cc74519 | |||
| 2ef9a52e6e | |||
| 05a31070e8 | |||
| 2ff28720f8 | |||
| 4a2b66dcf2 | |||
| 7fad5ec9dd | |||
| 2490ea2f75 | |||
| 767d7d19cf | |||
| 36d04f5c87 |
Generated
+2
-35
@@ -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">
|
||||
|
||||
Generated
+23
@@ -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>
|
||||
Generated
+1
@@ -10,6 +10,7 @@
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/macrobenchmark" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
+55
-23
@@ -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 {
|
||||
|
||||
Vendored
+145
-17
@@ -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
|
||||
|
||||
@@ -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,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,9 +133,25 @@ class MainActivity : AppCompatActivity() {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
val credentials by credentialsState.collectAsState()
|
||||
val appLockEnabled by appLockEnabledState.collectAsState()
|
||||
val isOnboarded = credentials?.isOnboarded == true
|
||||
|
||||
if (isOnboarded) {
|
||||
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()
|
||||
@@ -142,18 +164,17 @@ class MainActivity : AppCompatActivity() {
|
||||
pendingPaymentUri = pendingPaymentUri,
|
||||
lyricist = lyricist
|
||||
)
|
||||
} else {
|
||||
OnboardingScreen(
|
||||
secretStore = secretStore,
|
||||
syncStore = syncStore,
|
||||
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
||||
onComplete = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
isUnlocked.value = false
|
||||
}
|
||||
|
||||
// ── NFC foreground dispatch ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
val api: LNbitsApi by lazy { buildApi() }
|
||||
|
||||
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)
|
||||
.build()
|
||||
|
||||
chain.proceed(
|
||||
chain.request().newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
|
||||
.encodedPath(
|
||||
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
|
||||
)
|
||||
.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 tag: String? = null,
|
||||
// ── payRequest fields ─────────────────────────────────────────────────────
|
||||
val callback: String? = null,
|
||||
@SerializedName("minSendable") val minSendable: Long? = null,
|
||||
@SerializedName("maxSendable") val maxSendable: Long? = null,
|
||||
@SerialName("minSendable") val minSendable: Long? = null,
|
||||
@SerialName("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,
|
||||
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
||||
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||
@SerialName("nostrPubkey") val nostrPubkey: String? = null,
|
||||
val bolt11: String? = null,
|
||||
@SerializedName("amount_msat") val amountMsat: Long? = null,
|
||||
@SerialName("amount_msat") val amountMsat: Long? = null,
|
||||
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
||||
// ── withdrawRequest fields ───────────────────────────────────────────────
|
||||
// ── 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 ─────────────────────────────────────────────────────────
|
||||
@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 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,41 +149,44 @@ 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 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,
|
||||
@@ -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 }
|
||||
|
||||
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
|
||||
JsonToken.STRING -> {
|
||||
val text = reader.nextString()
|
||||
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()
|
||||
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 -> null
|
||||
}
|
||||
}
|
||||
reader.endObject()
|
||||
SuccessAction(tag = tag, message = message, url = url, description = description)
|
||||
}
|
||||
|
||||
else -> { reader.skipValue(); 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,7 +28,7 @@ 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +68,7 @@ class LnurlCacheRepository(context: Context) {
|
||||
}
|
||||
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,7 +36,7 @@ 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
|
||||
@@ -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) },
|
||||
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,6 +142,113 @@ val EnHomeStrings = AppStrings(
|
||||
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
|
||||
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
back = "Back",
|
||||
next = "Next",
|
||||
done = "Done",
|
||||
title = "Connect to LNbits",
|
||||
subtitle = "Enter your LNbits server URL and API keys.",
|
||||
serverUrl = "Server URL",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Invoice Key (read-only)",
|
||||
invoiceKeySubtitle = "Used to generate payment requests.",
|
||||
pasteInvoiceKey = "Paste invoice key",
|
||||
adminKey = "Admin Key (for sending)",
|
||||
adminKeySubtitle = "Required to send payments.",
|
||||
pasteAdminKey = "Paste admin key",
|
||||
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.",
|
||||
notificationsAlreadyGranted = "You're all set - permissions have been granted.",
|
||||
allowNotifications = "Allow Notifications",
|
||||
notificationsNote = "You can change this later in system Settings.",
|
||||
notificationsAutoGranted = "Notifications are enabled automatically on your device.",
|
||||
batteryTitle = "Background activity",
|
||||
batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.",
|
||||
openSettings = "Open Settings",
|
||||
batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.",
|
||||
batteryAlreadyUnrestricted = "You're all set — no restrictions detected.",
|
||||
paymentHistory = "Payment history",
|
||||
paymentHistorySubtitle = "Download your past payments so they're available offline.",
|
||||
syncNow = "Sync now",
|
||||
skipForNow = "Skip for now",
|
||||
syncScheduled = "Sync scheduled — will run when your device is online. You will get a notification when the sync is done. You can already start using the app.",
|
||||
continueToApp = "Continue to app →",
|
||||
connecting = "Connecting...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total payments" },
|
||||
paymentsSynced = { total -> "✓ $total payments synced"},
|
||||
syncComplete = "✓ Sync complete",
|
||||
syncFailed = "Sync failed. You can retry from Settings.",
|
||||
scheduleForLater = "Schedule for later",
|
||||
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",
|
||||
@@ -307,51 +360,128 @@ val EnHomeStrings = AppStrings(
|
||||
paymentRowNoMemo = "No memo",
|
||||
paymentRowPending = "Pending",
|
||||
paymentRowFailed = "Failed",
|
||||
),
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
nwc = NwcStrings(
|
||||
back = "Back",
|
||||
next = "Next",
|
||||
done = "Done",
|
||||
title = "Connect to LNbits",
|
||||
subtitle = "Enter your LNbits server URL and API keys.",
|
||||
serverUrl = "Server URL",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Invoice Key (read-only)",
|
||||
invoiceKeySubtitle = "Used to generate payment requests.",
|
||||
pasteInvoiceKey = "Paste invoice key",
|
||||
adminKey = "Admin Key (for sending)",
|
||||
adminKeySubtitle = "Required to send payments.",
|
||||
pasteAdminKey = "Paste admin key",
|
||||
hideAdminKey = "Hide",
|
||||
showAdminKey = "Show",
|
||||
fieldsRequired = "All fields are required.",
|
||||
connect = "Connect",
|
||||
notificationsTitle = "Stay in the loop",
|
||||
notificationsSubtitle = "Get notified instantly when you receive a payment.",
|
||||
notificationsAlreadyGranted = "You're all set - permissions have been granted.",
|
||||
allowNotifications = "Allow Notifications",
|
||||
notificationsNote = "You can change this later in system Settings.",
|
||||
notificationsAutoGranted = "Notifications are enabled automatically on your device.",
|
||||
batteryTitle = "Background activity",
|
||||
batterySubtitle = "Allow Gudari Wallet to run unrestricted so notifications arrive even when the app is closed.",
|
||||
openSettings = "Open Settings",
|
||||
batteryNote = "Tap 'Battery' → 'Unrestricted'. You can skip this and change it later.",
|
||||
batteryAlreadyUnrestricted = "You're all set — no restrictions detected.",
|
||||
paymentHistory = "Payment history",
|
||||
paymentHistorySubtitle = "Download your past payments so they're available offline.",
|
||||
syncNow = "Sync now",
|
||||
skipForNow = "Skip for now",
|
||||
syncScheduled = "Sync scheduled — will run when your device is online. You will get a notification when the sync is done. You can already start using the app.",
|
||||
continueToApp = "Continue to app →",
|
||||
connecting = "Connecting...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total payments" },
|
||||
paymentsSynced = { total -> "✓ $total payments synced"},
|
||||
syncComplete = "✓ Sync complete",
|
||||
syncFailed = "Sync failed. You can retry from Settings.",
|
||||
scheduleForLater = "Schedule for later",
|
||||
scheduleSync = "Schedule sync",
|
||||
cancel = "Cancel",
|
||||
schedule = "Schedule",
|
||||
cancel = "Cancel"
|
||||
)
|
||||
|
||||
// ── 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,6 +142,113 @@ val EsHomeStrings = AppStrings(
|
||||
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
|
||||
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
back = "Atrás",
|
||||
next = "Siguiente",
|
||||
done = "Hecho",
|
||||
title = "Conectar a LNbits",
|
||||
subtitle = "Introduce la URL de tu servidor LNbits y las claves de API.",
|
||||
serverUrl = "URL del servidor",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Clave de factura (solo lectura)",
|
||||
invoiceKeySubtitle = "Se usa para generar solicitudes de pago.",
|
||||
pasteInvoiceKey = "Pegar clave de factura",
|
||||
adminKey = "Clave de administrador (para enviar)",
|
||||
adminKeySubtitle = "Necesaria para enviar pagos.",
|
||||
pasteAdminKey = "Pegar clave de administrador",
|
||||
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.",
|
||||
notificationsAlreadyGranted = "Todo listo — los permisos han sido concedidos.",
|
||||
allowNotifications = "Permitir notificaciones",
|
||||
notificationsNote = "Puedes cambiar esto más tarde en los Ajustes del sistema.",
|
||||
notificationsAutoGranted = "Las notificaciones se activan automáticamente en tu dispositivo.",
|
||||
batteryTitle = "Actividad en segundo plano",
|
||||
batterySubtitle = "Permite que Gudari Wallet funcione sin restricciones para que las notificaciones lleguen incluso cuando la aplicación esté cerrada.",
|
||||
openSettings = "Abrir Ajustes",
|
||||
batteryNote = "Pulsa 'Batería' → 'Sin restricciones'. Puedes omitir esto y cambiarlo más tarde.",
|
||||
batteryAlreadyUnrestricted = "Todo listo — no se han detectado restricciones.",
|
||||
paymentHistory = "Historial de transacciones",
|
||||
paymentHistorySubtitle = "Descarga tus transacciones anteriores para tenerlas disponibles sin conexión.",
|
||||
syncNow = "Sincronizar ahora",
|
||||
skipForNow = "Omitir por ahora",
|
||||
syncScheduled = "Sincronización programada — se ejecutará cuando tu dispositivo esté en línea. Recibirás una notificación cuando la sincronización haya finalizado. Ya puedes empezar a usar la aplicación.",
|
||||
continueToApp = "Ir a la aplicación →",
|
||||
connecting = "Conectando...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transacciones" },
|
||||
paymentsSynced = { total -> "✓ $total transacciones sincronizadas"},
|
||||
syncComplete = "✓ Sincronización completada",
|
||||
syncFailed = "Error en la sincronización. Puedes volver a intentarlo desde Ajustes.",
|
||||
scheduleForLater = "Programar para más tarde",
|
||||
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",
|
||||
@@ -306,51 +360,127 @@ val EsHomeStrings = AppStrings(
|
||||
paymentRowNoMemo = "Sin nota",
|
||||
paymentRowPending = "Pendiente",
|
||||
paymentRowFailed = "Fallido",
|
||||
),
|
||||
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
nwc = NwcStrings(
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
back = "Atrás",
|
||||
next = "Siguiente",
|
||||
done = "Hecho",
|
||||
title = "Conectar a LNbits",
|
||||
subtitle = "Introduce la URL de tu servidor LNbits y las claves de API.",
|
||||
serverUrl = "URL del servidor",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Clave de factura (solo lectura)",
|
||||
invoiceKeySubtitle = "Se usa para generar solicitudes de pago.",
|
||||
pasteInvoiceKey = "Pegar clave de factura",
|
||||
adminKey = "Clave de administrador (para enviar)",
|
||||
adminKeySubtitle = "Necesaria para enviar pagos.",
|
||||
pasteAdminKey = "Pegar clave de administrador",
|
||||
hideAdminKey = "Ocultar",
|
||||
showAdminKey = "Mostrar",
|
||||
fieldsRequired = "Todos los campos son obligatorios.",
|
||||
connect = "Conectar",
|
||||
notificationsTitle = "Mantente al día",
|
||||
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
|
||||
notificationsAlreadyGranted = "Todo listo — los permisos han sido concedidos.",
|
||||
allowNotifications = "Permitir notificaciones",
|
||||
notificationsNote = "Puedes cambiar esto más tarde en los Ajustes del sistema.",
|
||||
notificationsAutoGranted = "Las notificaciones se activan automáticamente en tu dispositivo.",
|
||||
batteryTitle = "Actividad en segundo plano",
|
||||
batterySubtitle = "Permite que Gudari Wallet funcione sin restricciones para que las notificaciones lleguen incluso cuando la aplicación esté cerrada.",
|
||||
openSettings = "Abrir Ajustes",
|
||||
batteryNote = "Pulsa 'Batería' → 'Sin restricciones'. Puedes omitir esto y cambiarlo más tarde.",
|
||||
batteryAlreadyUnrestricted = "Todo listo — no se han detectado restricciones.",
|
||||
paymentHistory = "Historial de transacciones",
|
||||
paymentHistorySubtitle = "Descarga tus transacciones anteriores para tenerlas disponibles sin conexión.",
|
||||
syncNow = "Sincronizar ahora",
|
||||
skipForNow = "Omitir por ahora",
|
||||
syncScheduled = "Sincronización programada — se ejecutará cuando tu dispositivo esté en línea. Recibirás una notificación cuando la sincronización haya finalizado. Ya puedes empezar a usar la aplicación.",
|
||||
continueToApp = "Ir a la aplicación →",
|
||||
connecting = "Conectando...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transacciones" },
|
||||
paymentsSynced = { total -> "✓ $total transacciones sincronizadas"},
|
||||
syncComplete = "✓ Sincronización completada",
|
||||
syncFailed = "Error en la sincronización. Puedes volver a intentarlo desde Ajustes.",
|
||||
scheduleForLater = "Programar para más tarde",
|
||||
scheduleSync = "Programar sincronización",
|
||||
done = "Listo",
|
||||
cancel = "Cancelar",
|
||||
schedule = "Programar",
|
||||
cancel = "Cancelar"
|
||||
)
|
||||
|
||||
// ── 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,6 +143,113 @@ val EuStrings = AppStrings(
|
||||
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
|
||||
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
back = "Atzera",
|
||||
next = "Hurrengoa",
|
||||
done = "Eginda",
|
||||
title = "Konektatu LNbits-era",
|
||||
subtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.",
|
||||
serverUrl = "Zerbitzariaren URLa",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Faktura gakoa (irakurtzeko soilik)",
|
||||
invoiceKeySubtitle = "Ordainketa eskaerak sortzeko erabiltzen da.",
|
||||
pasteInvoiceKey = "Itsatsi faktura gakoa",
|
||||
adminKey = "Administrazio gakoa (bidaltzeko)",
|
||||
adminKeySubtitle = "Beharrezkoa da transakzioak bidaltzeko.",
|
||||
pasteAdminKey = "Itsatsi administrazio gakoa",
|
||||
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.",
|
||||
notificationsAlreadyGranted = "Dena prest dago — baimenak emanda daude.",
|
||||
allowNotifications = "Baimendu jakinarazpenak",
|
||||
notificationsNote = "Hau geroago alda dezakezu sistemaren Ezarpenetan.",
|
||||
notificationsAutoGranted = "Jakinarazpenak automatikoki gaituta daude zure gailuan.",
|
||||
batteryTitle = "Atzeko planoko jarduera",
|
||||
batterySubtitle = "Baimendu Gudari Wallet murrizketarik gabe funtzionatzea, jakinarazpenak aplikazioa itxita dagoenean ere irits daitezen.",
|
||||
openSettings = "Ireki Ezarpenak",
|
||||
batteryNote = "Sakatu 'Bateria' → 'Murrizketarik gabe'. Hau saltatu eta geroago alda dezakezu.",
|
||||
batteryAlreadyUnrestricted = "Dena prest dago — ez da murrizketarik hauteman.",
|
||||
paymentHistory = "Transakzioen historia",
|
||||
paymentHistorySubtitle = "Deskargatu iraganeko transakzioak konexiorik gabe erabilgarri izateko.",
|
||||
syncNow = "Sinkronizatu orain",
|
||||
skipForNow = "Saltatu oraingoz",
|
||||
syncScheduled = "Sinkronizazioa programatuta dago — gailua sarean dagoenean exekutatuko da. Sinkronizazioa amaitutakoan jakinarazpen bat jasoko duzu. Dagoeneko has zaitezke aplikazioa erabiltzen.",
|
||||
continueToApp = "Joan aplikaziora →",
|
||||
connecting = "Konektatzen...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transakzio" },
|
||||
paymentsSynced = { total -> "✓ $total transakzio sinkronizatuta"},
|
||||
syncComplete = "✓ Sinkronizazioa osatuta",
|
||||
syncFailed = "Sinkronizazioak huts egin du. Berriro saia zaitezke Ezarpenetatik.",
|
||||
scheduleForLater = "Programatu geroago",
|
||||
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",
|
||||
@@ -306,51 +360,127 @@ val EuStrings = AppStrings(
|
||||
paymentRowNoMemo = "Oharrik gabe",
|
||||
paymentRowPending = "Zain",
|
||||
paymentRowFailed = "Huts eginda",
|
||||
),
|
||||
|
||||
|
||||
onboarding = OnboardingStrings(
|
||||
nwc = NwcStrings(
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
back = "Atzera",
|
||||
next = "Hurrengoa",
|
||||
done = "Eginda",
|
||||
title = "Konektatu LNbits-era",
|
||||
subtitle = "Sartu zure LNbits zerbitzariaren URLa eta API gakoak.",
|
||||
serverUrl = "Zerbitzariaren URLa",
|
||||
serverUrlHint = "https://bitcointxoko.org",
|
||||
invoiceKey = "Faktura gakoa (irakurtzeko soilik)",
|
||||
invoiceKeySubtitle = "Ordainketa eskaerak sortzeko erabiltzen da.",
|
||||
pasteInvoiceKey = "Itsatsi faktura gakoa",
|
||||
adminKey = "Administrazio gakoa (bidaltzeko)",
|
||||
adminKeySubtitle = "Beharrezkoa da transakzioak bidaltzeko.",
|
||||
pasteAdminKey = "Itsatsi administrazio gakoa",
|
||||
hideAdminKey = "Ezkutatu",
|
||||
showAdminKey = "Erakutsi",
|
||||
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
|
||||
connect = "Konektatu",
|
||||
notificationsTitle = "Egon eguneratuta",
|
||||
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
|
||||
notificationsAlreadyGranted = "Dena prest dago — baimenak emanda daude.",
|
||||
allowNotifications = "Baimendu jakinarazpenak",
|
||||
notificationsNote = "Hau geroago alda dezakezu sistemaren Ezarpenetan.",
|
||||
notificationsAutoGranted = "Jakinarazpenak automatikoki gaituta daude zure gailuan.",
|
||||
batteryTitle = "Atzeko planoko jarduera",
|
||||
batterySubtitle = "Baimendu Gudari Wallet murrizketarik gabe funtzionatzea, jakinarazpenak aplikazioa itxita dagoenean ere irits daitezen.",
|
||||
openSettings = "Ireki Ezarpenak",
|
||||
batteryNote = "Sakatu 'Bateria' → 'Murrizketarik gabe'. Hau saltatu eta geroago alda dezakezu.",
|
||||
batteryAlreadyUnrestricted = "Dena prest dago — ez da murrizketarik hauteman.",
|
||||
paymentHistory = "Transakzioen historia",
|
||||
paymentHistorySubtitle = "Deskargatu iraganeko transakzioak konexiorik gabe erabilgarri izateko.",
|
||||
syncNow = "Sinkronizatu orain",
|
||||
skipForNow = "Saltatu oraingoz",
|
||||
syncScheduled = "Sinkronizazioa programatuta dago — gailua sarean dagoenean exekutatuko da. Sinkronizazioa amaitutakoan jakinarazpen bat jasoko duzu. Dagoeneko has zaitezke aplikazioa erabiltzen.",
|
||||
continueToApp = "Joan aplikaziora →",
|
||||
connecting = "Konektatzen...",
|
||||
paymentsFetchedTotal = { fetched, total -> "$fetched / $total transakzio" },
|
||||
paymentsSynced = { total -> "✓ $total transakzio sinkronizatuta"},
|
||||
syncComplete = "✓ Sinkronizazioa osatuta",
|
||||
syncFailed = "Sinkronizazioak huts egin du. Berriro saia zaitezke Ezarpenetatik.",
|
||||
scheduleForLater = "Programatu geroago",
|
||||
scheduleSync = "Programatu sinkronizazioa",
|
||||
cancel = "Utzi",
|
||||
schedule = "Programatu",
|
||||
cancel = "Utzi"
|
||||
|
||||
// ── 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,
|
||||
data class NwcStrings(
|
||||
|
||||
// ── Shared / navigation ───────────────────────────────────────────────────
|
||||
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 cancel : String,
|
||||
val schedule : String,
|
||||
val cancel: 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
-6
@@ -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,7 +54,7 @@ fun HomeTab(
|
||||
|
||||
val isRefreshing = balanceState is BalanceState.Loading
|
||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { vm.refreshBalance() },
|
||||
@@ -174,7 +178,9 @@ fun HomeTab(
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.75f
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
@@ -280,6 +286,27 @@ fun HomeTab(
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── NEW: Language switcher composable ─────────────────────────────────────────
|
||||
|
||||
@@ -92,7 +92,8 @@ sealed class SendState {
|
||||
val amountSats : Long,
|
||||
val feeSats : Long? = null,
|
||||
val paymentHash: String,
|
||||
val pendingRecord: PaymentRecord? = null
|
||||
val pendingRecord: PaymentRecord? = null,
|
||||
val lnurl : String? = null
|
||||
) : SendState()
|
||||
data class Error(val message: String) : SendState()
|
||||
}
|
||||
|
||||
@@ -57,16 +57,26 @@ import cafe.adriel.lyricist.Lyricist
|
||||
import com.bitcointxoko.gudariwallet.MainActivity
|
||||
import com.bitcointxoko.gudariwallet.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,114 @@ fun WalletScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(
|
||||
ROUTE_NWC,
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
) {
|
||||
NwcScreen(
|
||||
viewModel = nwcVm,
|
||||
onBack = {
|
||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||
},
|
||||
onConnectionClick = { pubkey ->
|
||||
navController.navigate("connection_detail/$pubkey") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = ROUTE_CONNECTION_DETAIL,
|
||||
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
) { backStackEntry ->
|
||||
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
|
||||
|
||||
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
|
||||
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
|
||||
|
||||
when (val state = connectionState) {
|
||||
is ConnectionDetailState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is ConnectionDetailState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ConnectionDetailState.Success -> {
|
||||
ConnectionDetailScreen(
|
||||
connection = state.connection,
|
||||
budgets = state.budgets,
|
||||
onBack = { navController.popBackStack() },
|
||||
onRevoke = {
|
||||
nwcVm.deleteConnection(pubkey, strings)
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(
|
||||
ROUTE_CONTACTS,
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
) {
|
||||
ContactsScreen(
|
||||
viewModel = contactsVm,
|
||||
onContactClick = { contactId ->
|
||||
navController.navigate("contacts/$contactId") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onBack = {
|
||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = ROUTE_CONTACT_DETAIL,
|
||||
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
) { backStackEntry ->
|
||||
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
||||
val detailVm: ContactDetailViewModel = viewModel(
|
||||
key = contactId,
|
||||
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
||||
)
|
||||
ContactDetailScreen(
|
||||
viewModel = detailVm,
|
||||
onBack = { navController.popBackStack() },
|
||||
onPayAddress = { address ->
|
||||
vm.prefillSend(address)
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = false // don't restore stale SendScreen state
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── Clipboard banner — floats over content, slides in from top ────
|
||||
|
||||
@@ -11,7 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||
@@ -19,7 +23,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
private const val TAG = "WalletViewModel"
|
||||
@@ -28,6 +34,8 @@ class WalletViewModel(
|
||||
val repo : WalletRepository,
|
||||
val aliasRepo : NodeAliasRepository,
|
||||
val paymentCache: PaymentCacheRepository,
|
||||
val nwcCache : NwcCacheRepository,
|
||||
val contactRepo : ContactRepository,
|
||||
private val balancePrefs: BalancePrefsStore,
|
||||
private val lnAddressStore : LightningAddressStore,
|
||||
val balanceVm : BalanceViewModel,
|
||||
@@ -109,6 +117,16 @@ class WalletViewModel(
|
||||
|
||||
val sendEvents: SharedFlow<SendEvent> = sendVm.events
|
||||
|
||||
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
|
||||
private val _pendingSend = MutableStateFlow<String?>(null)
|
||||
val pendingSend: StateFlow<String?> get() = _pendingSend
|
||||
|
||||
/** Called by ContactDetailScreen before navigating to the Send tab. */
|
||||
fun prefillSend(address: String) { _pendingSend.value = address }
|
||||
|
||||
/** Called by SendScreen once it has consumed the prefilled address. */
|
||||
fun consumePendingSend() { _pendingSend.value = null }
|
||||
|
||||
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
|
||||
receiveVm.handleWithdrawResponse(raw, lnurl)
|
||||
}
|
||||
@@ -124,6 +142,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,10 +75,9 @@ 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")
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun AmountWithFiatColumn(
|
||||
amountSat : Long,
|
||||
isOutgoing : Boolean,
|
||||
amountColor : Color,
|
||||
fiatLine : String?,
|
||||
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||
) {
|
||||
val prefix = if (isOutgoing) "−" else "+"
|
||||
Column(horizontalAlignment = horizontalAlignment) {
|
||||
Text(
|
||||
text = "$prefix$amountSat sats",
|
||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||
color = amountColor
|
||||
)
|
||||
if (fiatLine != null) {
|
||||
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
||||
Text(
|
||||
text = fiatLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
/**
|
||||
* Manages screen brightness for QR-display screens.
|
||||
* Returns a [BrightnessController] with [isOn] state and a [toggle] callback.
|
||||
* Restores system brightness unconditionally when it leaves composition.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberBrightnessController(): BrightnessController {
|
||||
val window = (LocalActivity.current)?.window
|
||||
var isOn by remember { mutableStateOf(false) }
|
||||
|
||||
// Sync side effect: no coroutine needed
|
||||
SideEffect {
|
||||
window?.attributes = window.attributes?.apply {
|
||||
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
window?.attributes = window.attributes?.apply {
|
||||
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No remember needed — BrightnessController is a tiny data class,
|
||||
// and this composable already recomposes only when isOn changes.
|
||||
return BrightnessController(isOn = isOn, toggle = { isOn = !isOn })
|
||||
}
|
||||
|
||||
data class BrightnessController(
|
||||
val isOn : Boolean,
|
||||
val toggle : () -> Unit
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
||||
/**
|
||||
* A small icon button that copies [value] to the clipboard under [label].
|
||||
*/
|
||||
@Composable
|
||||
fun CopyIconButton(
|
||||
label : String,
|
||||
value : String,
|
||||
size : Dp = 32.dp,
|
||||
iconSize : Dp = 16.dp,
|
||||
tint : Color = Color.Unspecified
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(size)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = "Copy $label",
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = if (tint == Color.Unspecified)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else tint
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DetailRow(
|
||||
label : String,
|
||||
value : String,
|
||||
subtitle : String? = null, // ← from components
|
||||
monospace : Boolean = false, // ← from history
|
||||
copyable : Boolean = false, // ← from history
|
||||
onCopy : (() -> Unit)? = null, // ← from history
|
||||
maxLines : Int = Int.MAX_VALUE,
|
||||
valueColor : Color = Color.Unspecified,
|
||||
textDecoration : TextDecoration? = null,
|
||||
onValueClick : (() -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val baseStyle = MaterialTheme.typography.bodySmall
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = baseStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(0.35f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.weight(0.65f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
Text(
|
||||
text = value,
|
||||
style = baseStyle.copy(
|
||||
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
|
||||
textDecoration = textDecoration
|
||||
),
|
||||
color = valueColor,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
|
||||
else Modifier
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (copyable) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ContentCopy,
|
||||
contentDescription = "Copy $label",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DetailSection(
|
||||
title : String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
||||
|
||||
/**
|
||||
* Shared success screen used by both Send (PaymentSent) and Receive (PaymentReceived).
|
||||
*
|
||||
* @param title Headline string, e.g. strings.paymentSent / strings.receive.paymentReceived
|
||||
* @param amountSats Amount in sats.
|
||||
* @param fiatLabel Optional fiat conversion string (from [fiatLabel] util).
|
||||
* @param subLine Optional secondary line below amount: fee text (send) or memo (receive).
|
||||
* @param detailsEnabled Whether the Details button is clickable.
|
||||
* @param onDetails Navigate to payment detail.
|
||||
* @param secondaryLabel Label of the reset TextButton ("Send Another" / "Done").
|
||||
* @param onSecondary Reset screen state.
|
||||
* @param lnurl Pre-fill address for the save-contact sheet. Pass null to hide the button.
|
||||
* @param onSaveContact Forward to vm.saveContactFromSend — omit (null) to hide save-contact UI.
|
||||
*/
|
||||
@Composable
|
||||
fun PaymentSuccessContent(
|
||||
title : String,
|
||||
amountSats : Long,
|
||||
fiatLabel : String?,
|
||||
subLine : String? = null,
|
||||
detailsEnabled : Boolean = true,
|
||||
onDetails : () -> Unit,
|
||||
secondaryLabel : String,
|
||||
onSecondary : () -> Unit,
|
||||
lnurl : String? = null,
|
||||
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
|
||||
) {
|
||||
var showSaveSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||
if (!subLine.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// ── Save contact affordance (send-side only) ─────────────────────────
|
||||
if (lnurl != null && onSaveContact != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedButton(
|
||||
onClick = { showSaveSheet = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Save contact")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = onDetails,
|
||||
enabled = detailsEnabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(LocalAppStrings.current.details) }
|
||||
Spacer(Modifier.height(32.dp))
|
||||
TextButton(
|
||||
onClick = onSecondary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(secondaryLabel) }
|
||||
}
|
||||
|
||||
// ── Bottom sheet ─────────────────────────────────────────────────────────
|
||||
if (showSaveSheet && lnurl != null && onSaveContact != null) {
|
||||
// Detect whether the string is a Lightning Address (user@domain) or LNURL
|
||||
val isLnAddress = lnurl.contains('@') && !lnurl.startsWith("lnurl", ignoreCase = true)
|
||||
val initialType = if (isLnAddress)
|
||||
PaymentAddressType.LIGHTNING_ADDRESS
|
||||
else
|
||||
PaymentAddressType.LNURL
|
||||
|
||||
CreateContactSheet(
|
||||
initialAddress = lnurl,
|
||||
initialAddressType = initialType,
|
||||
onSave = { name, addressType, address ->
|
||||
onSaveContact(name, addressType, address)
|
||||
showSaveSheet = false
|
||||
},
|
||||
onDismiss = { showSaveSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
+39
-9
@@ -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
-1
@@ -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,652 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Star
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactDetailScreen(
|
||||
viewModel: ContactDetailViewModel,
|
||||
onBack: () -> Unit,
|
||||
onPayAddress: (address: String) -> Unit = {}
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
||||
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
||||
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
val title = when (val s = uiState) {
|
||||
is ContactDetailUiState.Success ->
|
||||
s.data.contact.localAlias
|
||||
?: s.data.contact.displayName
|
||||
?: s.data.contact.name
|
||||
?: "Contact"
|
||||
else -> "Contact"
|
||||
}
|
||||
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = strings.back
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (uiState is ContactDetailUiState.Success) {
|
||||
if (isEditing) {
|
||||
// Done button exits edit mode
|
||||
TextButton(onClick = { isEditing = false }) {
|
||||
Text("Done")
|
||||
}
|
||||
} else {
|
||||
// Edit button opens edit mode
|
||||
IconButton(onClick = { isEditing = true }) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// FAB only visible in edit mode so users don't accidentally add
|
||||
// addresses while browsing
|
||||
if (uiState is ContactDetailUiState.Success && isEditing) {
|
||||
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
when (val state = uiState) {
|
||||
is ContactDetailUiState.Loading -> {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is ContactDetailUiState.NotFound -> {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Contact not found",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ContactDetailUiState.Success -> {
|
||||
ContactDetailContent(
|
||||
state = state,
|
||||
isEditing = isEditing,
|
||||
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
|
||||
onSetDefault = { viewModel.setDefaultAddress(it) },
|
||||
onPayAddress = onPayAddress,
|
||||
onEditAlias = { viewModel.openEditAliasSheet() },
|
||||
onDeleteContact = { viewModel.requestDelete() },
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
|
||||
if (pendingDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.cancelDelete() },
|
||||
title = { Text("Delete contact") },
|
||||
text = { Text("This contact and all their addresses will be permanently removed.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.cancelDelete() }) {
|
||||
Text(strings.cancel)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingAddressDelete?.let { addressId ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAddressDelete = null },
|
||||
title = { Text("Remove address") },
|
||||
text = { Text("This payment address will be permanently removed from the contact.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.removeAddress(addressId)
|
||||
pendingAddressDelete = null
|
||||
}) {
|
||||
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingAddressDelete = null }) {
|
||||
Text(strings.cancel)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add address sheet ─────────────────────────────────────────────────
|
||||
if (showAddAddressSheet) {
|
||||
AddAddressSheet(
|
||||
onSave = { type, address, label, makeDefault ->
|
||||
viewModel.addAddress(type, address, label, makeDefault)
|
||||
viewModel.closeAddAddressSheet()
|
||||
},
|
||||
onDismiss = { viewModel.closeAddAddressSheet() }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Edit alias sheet ──────────────────────────────────────────────────
|
||||
if (showEditAliasSheet) {
|
||||
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
||||
?.data?.contact?.localAlias ?: ""
|
||||
EditAliasSheet(
|
||||
currentAlias = currentAlias,
|
||||
onSave = { newAlias ->
|
||||
viewModel.updateAlias(newAlias)
|
||||
viewModel.closeEditAliasSheet()
|
||||
},
|
||||
onDismiss = { viewModel.closeEditAliasSheet() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main content ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ContactDetailContent(
|
||||
state: ContactDetailUiState.Success,
|
||||
isEditing: Boolean,
|
||||
onRemoveAddress: (addressId: String) -> Unit,
|
||||
onSetDefault: (PaymentAddressEntity) -> Unit,
|
||||
onPayAddress: (address: String) -> Unit,
|
||||
onEditAlias: () -> Unit,
|
||||
onDeleteContact: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val contact = state.data.contact
|
||||
val addresses = state.data.addresses
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
|
||||
) {
|
||||
// ── Avatar + name hero ─────────────────────────────────────────────
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val displayName = contact.localAlias
|
||||
?: contact.displayName
|
||||
?: contact.name
|
||||
?: "?"
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.size(72.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = displayName.first().uppercaseChar().toString(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
contact.nip05?.let { nip05 ->
|
||||
Text(
|
||||
text = nip05,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment addresses section ─────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = "Payment addresses") {
|
||||
if (addresses.isEmpty()) {
|
||||
Text(
|
||||
text = "No addresses added yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
addresses.forEachIndexed { index, addr ->
|
||||
AddressRow(
|
||||
address = addr,
|
||||
isEditing = isEditing,
|
||||
onPay = { onPayAddress(addr.address) },
|
||||
onRemove = { onRemoveAddress(addr.id) },
|
||||
onSetDefault = { onSetDefault(addr) }
|
||||
)
|
||||
if (index < addresses.lastIndex) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
|
||||
contact.about?.let { about ->
|
||||
item {
|
||||
DetailSection(title = "About") {
|
||||
DetailRow(label = "Bio", value = about, maxLines = 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Destructive actions (edit mode only) ─────────────────────────
|
||||
if (isEditing) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onEditAlias,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Edit name")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onDeleteContact,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Delete contact")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Address row ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AddressRow(
|
||||
address: PaymentAddressEntity,
|
||||
isEditing: Boolean,
|
||||
onPay: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onSetDefault: () -> Unit,
|
||||
) {
|
||||
val typeLabel = when (address.type) {
|
||||
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
||||
PaymentAddressType.LNURL.name -> "LNURL"
|
||||
PaymentAddressType.ON_CHAIN.name -> "On-chain"
|
||||
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
|
||||
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
|
||||
PaymentAddressType.BIP353.name -> "BIP-353"
|
||||
else -> address.type
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = !isEditing,
|
||||
onClickLabel = "Pay with this address"
|
||||
) { onPay() }
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// ── Label + value ─────────────────────────────────────────────
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = typeLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
address.label?.let { lbl ->
|
||||
Text(
|
||||
text = "· $lbl",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (address.isDefault) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
"default",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
modifier = Modifier.height(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = address.address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// ── Trailing actions ──────────────────────────────────────────
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
// Copy always available
|
||||
CopyIconButton(
|
||||
label = "Copy address",
|
||||
value = address.address,
|
||||
size = 36.dp
|
||||
)
|
||||
// Star + delete only in edit mode
|
||||
if (isEditing) {
|
||||
IconButton(
|
||||
onClick = onSetDefault,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (address.isDefault) Icons.Filled.Star
|
||||
else Icons.Outlined.Star,
|
||||
contentDescription = if (address.isDefault) "Default address"
|
||||
else "Set as default",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove address",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// ── Add address sheet ──────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddAddressSheet(
|
||||
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val addressFocus = remember { FocusRequester() }
|
||||
|
||||
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var label by remember { mutableStateOf("") }
|
||||
var makeDefault by remember { mutableStateOf(false) }
|
||||
|
||||
val canSave = address.isNotBlank()
|
||||
|
||||
LaunchedEffect(Unit) { addressFocus.requestFocus() }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// Type chips
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
|
||||
label = { Text("Lightning Address") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedType == PaymentAddressType.LNURL,
|
||||
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
|
||||
label = { Text("LNURL") }
|
||||
)
|
||||
}
|
||||
|
||||
// Address field
|
||||
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||
"Lightning Address" else "LNURL"
|
||||
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||
"user@domain.com" else "LNURL1..."
|
||||
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = { address = it },
|
||||
label = { Text(addressLabel) },
|
||||
placeholder = { Text(addressPlaceholder) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(addressFocus)
|
||||
)
|
||||
|
||||
// Optional label
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
label = { Text("Label (optional)") },
|
||||
placeholder = { Text("e.g. personal, savings") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||
}
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Set as default toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Set as default",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Switch(
|
||||
checked = makeDefault,
|
||||
onCheckedChange = { makeDefault = it }
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||
},
|
||||
enabled = canSave
|
||||
) { Text("Save") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditAliasSheet(
|
||||
currentAlias: String,
|
||||
onSave: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
var alias by remember { mutableStateOf(currentAlias) }
|
||||
val canSave = alias.isNotBlank()
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Edit name", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = alias,
|
||||
onValueChange = { alias = it },
|
||||
label = { Text("Name") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (canSave) onSave(alias)
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||
Button(
|
||||
onClick = { focusManager.clearFocus(); onSave(alias) },
|
||||
enabled = canSave
|
||||
) { Text("Save") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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) // ← "X–Y sats"
|
||||
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y 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( // ← "X–Y"
|
||||
formatEpochShort(minCreatedAt),
|
||||
formatEpochShort(maxCreatedAt)
|
||||
strings.history.filterDateRange( // ← "X–Y"
|
||||
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
|
||||
|
||||
+184
-49
@@ -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
|
||||
@@ -81,20 +98,37 @@ fun PaymentDetailScreen(
|
||||
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(
|
||||
topBar = {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,7 +159,12 @@ fun PaymentDetailScreen(
|
||||
PaymentDetailContent(
|
||||
payment = enriched,
|
||||
fiatCurrency = fiatCurrency,
|
||||
fiatSatsPerUnit = fiatSatsPerUnit
|
||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||
linkedContacts = linkedContacts,
|
||||
onAssignContact = { showContactPicker = true },
|
||||
onUnassignContact = { contactId ->
|
||||
vm.unassignContact(payment.checkingId, contactId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,20 +174,60 @@ fun PaymentDetailScreen(
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Detail content ───────────────────────────────────────────────────────────
|
||||
// ── 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun PaymentDetailContent(
|
||||
payment : PaymentRecord,
|
||||
fiatCurrency : String?,
|
||||
fiatSatsPerUnit : Double?,
|
||||
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||
onAssignContact : () -> Unit,
|
||||
onUnassignContact : (contactId: String) -> Unit,
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
@@ -178,7 +257,7 @@ private fun PaymentDetailContent(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
// ── Hero amount ──────────────────────────────────────────────────────
|
||||
// ── Hero amount ────────────────────────────────────────────────────────
|
||||
item {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
@@ -206,12 +285,9 @@ private fun PaymentDetailContent(
|
||||
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 feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = feeLabel,
|
||||
@@ -225,39 +301,114 @@ 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(
|
||||
strings.detailRowMemo, // ← "Memo"
|
||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—"
|
||||
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.history.detailRowDate,
|
||||
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||
)
|
||||
DetailRow(
|
||||
strings.history.detailRowMemo,
|
||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
|
||||
)
|
||||
if (payment.extra?.tag != null) {
|
||||
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type"
|
||||
DetailRow(strings.history.detailRowType, payment.extra.tag)
|
||||
}
|
||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment"
|
||||
DetailRow(strings.history.detailRowComment, payment.extra.comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
||||
// ── Success action (LNURL) ─────────────────────────────────────────────
|
||||
payment.extra?.successAction?.let { action ->
|
||||
item {
|
||||
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action"
|
||||
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message"
|
||||
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL"
|
||||
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description"
|
||||
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Technical ────────────────────────────────────────────────────────
|
||||
// ── Technical ─────────────────────────────────────────────────────────
|
||||
item {
|
||||
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical"
|
||||
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||
DetailRow(
|
||||
label = strings.detailRowPaymentHash, // ← "Payment Hash"
|
||||
label = strings.history.detailRowPaymentHash,
|
||||
value = payment.paymentHash,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
@@ -271,7 +422,7 @@ private fun PaymentDetailContent(
|
||||
)
|
||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||
DetailRow(
|
||||
label = strings.detailRowPreimage, // ← "Preimage"
|
||||
label = strings.history.detailRowPreimage,
|
||||
value = payment.preimage,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
@@ -288,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,29 +447,13 @@ 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"
|
||||
label = strings.history.detailRowBolt11,
|
||||
value = payment.bolt11,
|
||||
monospace = true,
|
||||
copyable = true,
|
||||
|
||||
@@ -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
|
||||
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,16 +174,27 @@ 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)
|
||||
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,16 @@ 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.PaymentSuccessContent
|
||||
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
||||
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
||||
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||
import com.bitcointxoko.gudariwallet.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(
|
||||
@@ -75,31 +74,16 @@ fun ReceiveScreen(
|
||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
||||
) {
|
||||
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 +125,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 +147,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() }
|
||||
@@ -173,16 +157,21 @@ fun ReceiveScreen(
|
||||
}
|
||||
|
||||
is ReceiveState.PaymentReceived -> {
|
||||
ReceiveSuccessContent(
|
||||
PaymentSuccessContent(
|
||||
title = strings.receive.paymentReceived,
|
||||
amountSats = state.amountSats,
|
||||
memo = state.memo,
|
||||
fiatRate = fiatRate,
|
||||
fiatCurrency = fiatCurrency,
|
||||
onDone = { vm.resetReceiveState() },
|
||||
onDetails = { onNavigateToPaymentDetail(state.checkingId) }
|
||||
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||
subLine = state.memo?.takeIf { it.isNotBlank() },
|
||||
onDetails = { onNavigateToPaymentDetail(state.checkingId) },
|
||||
secondaryLabel = strings.done,
|
||||
onSecondary = { vm.resetReceiveState() },
|
||||
lnurl = null, // sender identity not available
|
||||
onSaveContact = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
is ReceiveState.Error -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
@@ -190,7 +179,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 +205,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 +232,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 +254,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 +293,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 +304,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 +328,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 +386,7 @@ private fun ReceiveIdleContent(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = strings.receive, // ← "Receive"
|
||||
text = strings.receive.receive, // ← "Receive"
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
if (lightningAddress != null) {
|
||||
@@ -439,9 +428,9 @@ private fun ReceiveIdleContent(
|
||||
|
||||
QrDisplayCard(
|
||||
content = lightningAddress,
|
||||
contentDescription = strings.lightningAddressQrCode, // ← "Lightning address QR code"
|
||||
clipLabel = strings.lightningAddressLabel, // ← "Lightning Address"
|
||||
shareTitle = strings.shareLightningAddress, // ← "Share Lightning Address"
|
||||
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 +501,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 +549,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 +594,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 +610,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 +643,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)
|
||||
}
|
||||
@@ -683,9 +672,9 @@ private fun ReceiveInvoiceContent(
|
||||
|
||||
QrDisplayCard(
|
||||
content = state.bolt11.uppercase(),
|
||||
contentDescription = strings.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
|
||||
clipLabel = strings.invoiceLabel, // ← "Invoice"
|
||||
shareTitle = strings.shareInvoice, // ← "Share Invoice"
|
||||
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,
|
||||
@@ -711,7 +700,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 +711,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,56 +723,9 @@ private fun ReceiveInvoiceContent(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (isExpired) strings.newInvoice // ← "New Invoice"
|
||||
if (isExpired) strings.receive.newInvoice // ← "New Invoice"
|
||||
else strings.cancel // ← "Cancel"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReceiveSuccessContent ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ReceiveSuccessContent(
|
||||
amountSats : Long,
|
||||
memo : String?,
|
||||
fiatRate : Double?,
|
||||
fiatCurrency : String?,
|
||||
onDone : () -> Unit,
|
||||
onDetails : () -> Unit
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = strings.paymentReceived, // ← "Payment Received!"
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||
if (!memo.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(memo, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(strings.details) // ← "Details"
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(strings.done) // ← "Done"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user