Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f0badcdf5 | |||
| 3c5539d2cb | |||
| 19970659b8 | |||
| c6885f6307 | |||
| d5c461c2e6 | |||
| 985c80373d | |||
| bcea6451e7 | |||
| 53cb600408 | |||
| effe8f4b04 | |||
| c817768e8a | |||
| 0e7469053d | |||
| ebeac511b7 | |||
| 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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
<DropdownSelection timestamp="2026-06-11T09:22:11.580091Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
|
||||||
@@ -13,40 +13,7 @@
|
|||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
<SelectionState runConfigName="getAll_baseline()">
|
<SelectionState runConfigName="macrobenchmark">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
|
||||||
<Target type="DEFAULT_BOOT">
|
|
||||||
<handle>
|
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
|
|
||||||
</handle>
|
|
||||||
</Target>
|
|
||||||
</DropdownSelection>
|
|
||||||
<DialogSelection />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="PaymentDaoBenchmark">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
|
||||||
<Target type="DEFAULT_BOOT">
|
|
||||||
<handle>
|
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
|
|
||||||
</handle>
|
|
||||||
</Target>
|
|
||||||
</DropdownSelection>
|
|
||||||
<DialogSelection />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="concurrentReadWrite()">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
|
||||||
<Target type="DEFAULT_BOOT">
|
|
||||||
<handle>
|
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=48090DLAQ00119" />
|
|
||||||
</handle>
|
|
||||||
</Target>
|
|
||||||
</DropdownSelection>
|
|
||||||
<DialogSelection />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="checkJournalMode()">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
<DropdownSelection timestamp="2026-05-31T17:55:52.851171Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
|
|||||||
Generated
+23
@@ -2,17 +2,40 @@
|
|||||||
<dictionary name="project">
|
<dictionary name="project">
|
||||||
<words>
|
<words>
|
||||||
<w>Aead</w>
|
<w>Aead</w>
|
||||||
|
<w>Bech</w>
|
||||||
|
<w>Bitrefill</w>
|
||||||
|
<w>Español</w>
|
||||||
<w>Nbits</w>
|
<w>Nbits</w>
|
||||||
|
<w>Nostr</w>
|
||||||
<w>Tink</w>
|
<w>Tink</w>
|
||||||
<w>amboss</w>
|
<w>amboss</w>
|
||||||
|
<w>bech</w>
|
||||||
|
<w>bitcointxoko</w>
|
||||||
|
<w>btcpay</w>
|
||||||
|
<w>cltv</w>
|
||||||
|
<w>getnwckey</w>
|
||||||
|
<w>gudari</w>
|
||||||
|
<w>gudariwallet</w>
|
||||||
<w>hkdf</w>
|
<w>hkdf</w>
|
||||||
<w>inflight</w>
|
<w>inflight</w>
|
||||||
|
<w>keyauth</w>
|
||||||
|
<w>lnaddr</w>
|
||||||
|
<w>lnaddress</w>
|
||||||
<w>lnbc</w>
|
<w>lnbc</w>
|
||||||
<w>lnbits</w>
|
<w>lnbits</w>
|
||||||
<w>lnurlp</w>
|
<w>lnurlp</w>
|
||||||
|
<w>lnurlw</w>
|
||||||
<w>msat</w>
|
<w>msat</w>
|
||||||
|
<w>msats</w>
|
||||||
|
<w>nostr</w>
|
||||||
|
<w>nwccacherepository</w>
|
||||||
|
<w>satoshi</w>
|
||||||
<w>satoshis</w>
|
<w>satoshis</w>
|
||||||
<w>sats</w>
|
<w>sats</w>
|
||||||
|
<w>snackbar</w>
|
||||||
|
<w>snull</w>
|
||||||
|
<w>tink</w>
|
||||||
|
<w>walletconnect</w>
|
||||||
</words>
|
</words>
|
||||||
</dictionary>
|
</dictionary>
|
||||||
</component>
|
</component>
|
||||||
Generated
+1
@@ -10,6 +10,7 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/macrobenchmark" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
|
|||||||
+55
-23
@@ -1,4 +1,9 @@
|
|||||||
import com.google.protobuf.gradle.*
|
import com.google.protobuf.gradle.*
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val localProps = Properties().apply {
|
||||||
|
load(rootProject.file("local.properties").inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
configurations.all {
|
configurations.all {
|
||||||
resolutionStrategy {
|
resolutionStrategy {
|
||||||
@@ -12,49 +17,64 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.protobuf)
|
alias(libs.plugins.protobuf)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.bitcointxoko.gudariwallet"
|
namespace = "com.bitcointxoko.gudariwallet"
|
||||||
compileSdk {
|
compileSdk = 37
|
||||||
version = release(36) {
|
splits {
|
||||||
minorApiLevel = 1
|
abi {
|
||||||
|
isEnable = true
|
||||||
|
reset() // Clear all defaults
|
||||||
|
include("arm64-v8a") // Specify desired ABIs
|
||||||
|
isUniversalApk = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.bitcointxoko.gudariwallet"
|
applicationId = "com.bitcointxoko.gudariwallet"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
androidResources {
|
||||||
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
@Suppress("UnstableApiUsage")
|
||||||
|
localeFilters += setOf("en", "es", "eu")
|
||||||
|
}
|
||||||
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
|
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
|
||||||
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "ACTIVITY-MISSING"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testBuildType = "benchmark"
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(localProps["KEYSTORE_PATH"] as String)
|
||||||
|
storePassword = localProps["KEYSTORE_PASSWORD"] as String
|
||||||
|
keyAlias = localProps["KEY_ALIAS"] as String
|
||||||
|
keyPassword = localProps["KEY_PASSWORD"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true // Enables R8/ProGuard shrinking
|
||||||
|
isShrinkResources = true // Removes unused resources
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
create("benchmark") {
|
create("benchmark") {
|
||||||
initWith(buildTypes.getByName("release"))
|
initWith(buildTypes.getByName("release"))
|
||||||
|
isMinifyEnabled = false // benchmarks must not be minified
|
||||||
|
//noinspection NotShrinkingResources
|
||||||
|
isShrinkResources = false // or results are meaningless
|
||||||
signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment
|
signingConfig = signingConfigs.getByName("debug") // sign with debug key for easy deployment
|
||||||
installation {
|
|
||||||
enableBaselineProfile = false // ← disables profile embedding for this build type only
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
@@ -67,13 +87,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
java.srcDirs("build/generated/ksp/debug/kotlin")
|
java.directories.add("build/generated/ksp/debug/kotlin")
|
||||||
|
}
|
||||||
|
getByName("test") {
|
||||||
|
java.srcDirs("src/test/java")
|
||||||
|
kotlin.srcDirs("src/test/java")
|
||||||
}
|
}
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
java.srcDirs("build/generated/ksp/release/kotlin")
|
java.directories.add("build/generated/ksp/release/kotlin")
|
||||||
}
|
}
|
||||||
getByName("benchmark") {
|
getByName("benchmark") {
|
||||||
java.srcDirs("build/generated/ksp/benchmark/kotlin")
|
java.directories.add("build/generated/ksp/benchmark/kotlin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +106,7 @@ androidComponents {
|
|||||||
onVariants(selector().withBuildType("benchmark")) { variant ->
|
onVariants(selector().withBuildType("benchmark")) { variant ->
|
||||||
variant.packaging.resources.excludes.add("baseline-prof.txt")
|
variant.packaging.resources.excludes.add("baseline-prof.txt")
|
||||||
variant.packaging.resources.excludes.add("baseline.profm")
|
variant.packaging.resources.excludes.add("baseline.profm")
|
||||||
|
variant.packaging.resources.excludes.add("**/*.profm")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +123,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlin.test)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
implementation(libs.androidx.benchmark.junit4)
|
androidTestImplementation(libs.androidx.benchmark.junit4)
|
||||||
androidTestImplementation(libs.androidx.runner)
|
androidTestImplementation(libs.androidx.runner)
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
@@ -110,9 +136,9 @@ dependencies {
|
|||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
implementation(libs.retrofit2.retrofit)
|
implementation(libs.retrofit2.retrofit)
|
||||||
implementation(libs.converter.gson)
|
implementation(libs.converter.kotlinx.serialization)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.logging.interceptor)
|
debugImplementation(libs.logging.interceptor)
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
@@ -141,7 +167,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.biometric)
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
implementation(libs.gson)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
implementation(libs.androidx.material.icons.extended)
|
implementation(libs.androidx.material.icons.extended)
|
||||||
@@ -151,7 +177,6 @@ dependencies {
|
|||||||
|
|
||||||
//Navigation
|
//Navigation
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
// animations
|
// animations
|
||||||
@@ -159,6 +184,8 @@ dependencies {
|
|||||||
|
|
||||||
// secp256k1
|
// secp256k1
|
||||||
implementation(libs.secp256k1.kmp.android)
|
implementation(libs.secp256k1.kmp.android)
|
||||||
|
testImplementation(libs.secp256k1.kmp.jvm)
|
||||||
|
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm:0.15.0")
|
||||||
|
|
||||||
// lyricist
|
// lyricist
|
||||||
implementation(libs.lyricist)
|
implementation(libs.lyricist)
|
||||||
@@ -168,6 +195,11 @@ dependencies {
|
|||||||
|
|
||||||
// timber
|
// timber
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
|
|
||||||
|
// leakcanary
|
||||||
|
debugImplementation(libs.leakcanary.android)
|
||||||
|
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
|
|||||||
Vendored
+145
-17
@@ -1,21 +1,149 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# You can control the set of applied configuration files using the
|
# Protobuf lite
|
||||||
# proguardFiles setting in build.gradle.
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Google Tink 1.13.0
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keepclassmembers class * extends com.google.crypto.tink.shaded.protobuf.GeneratedMessageLite {
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
-keep class com.google.crypto.tink.KeyTemplates { *; }
|
||||||
|
-keep class com.google.crypto.tink.StreamingAead { *; }
|
||||||
|
-keep class com.google.crypto.tink.KeysetHandle { *; }
|
||||||
|
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager { *; }
|
||||||
|
-keep class com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder { *; }
|
||||||
|
-keep class com.google.crypto.tink.streamingaead.StreamingAeadConfig { *; }
|
||||||
|
-keep class com.google.crypto.tink.streamingaead.AesGcmHkdfStreamingKeyManager { *; }
|
||||||
|
-keep class com.google.crypto.tink.streamingaead.StreamingAeadWrapper { *; }
|
||||||
|
-keep class com.google.crypto.tink.subtle.AesGcmHkdfStreaming { *; }
|
||||||
|
-dontwarn com.google.crypto.tink.**
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# kotlinx.serialization
|
||||||
|
# The compiler plugin generates a $serializer inner class for every
|
||||||
|
# @Serializable class. R8 must not remove or rename these — they are
|
||||||
|
# looked up by name at runtime via SerializersModule.
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-keep class kotlinx.serialization.SerializersKt { *; }
|
||||||
|
-dontnote kotlinx.serialization.**
|
||||||
|
-dontwarn kotlinx.serialization.**
|
||||||
|
|
||||||
|
# Keep generated serializers for all @Serializable classes in your app
|
||||||
|
-keepclassmembers class com.bitcointxoko.gudariwallet.** {
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep the $serializer companion objects themselves
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.**$$serializer { *; }
|
||||||
|
|
||||||
|
# Required for sealed class polymorphic serialization
|
||||||
|
-keepclassmembers @kotlinx.serialization.Serializable class * {
|
||||||
|
*** Companion;
|
||||||
|
*** INSTANCE;
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Retrofit 3.0.0
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||||
|
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||||
|
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||||
|
-keep,allowobfuscation,allowshrinking class * extends kotlin.coroutines.CoroutineContext$Element { *; }
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# OkHttp 5.x / Okio
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep class okhttp3.internal.publicsuffix.PublicSuffixDatabase { *; }
|
||||||
|
-keepnames class okhttp3.** { *; }
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
|
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# secp256k1-kmp (JNI)
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep class fr.acinq.secp256k1.** { *; }
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# ZXing 4.3.0
|
||||||
#
|
#
|
||||||
# For more details, see
|
# ZXing uses reflection to instantiate Reader subclasses by class name.
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# Only the classes your app actually exercises need to be kept.
|
||||||
|
# A Lightning wallet only needs QR code scanning (QR_CODE format).
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
# Core entry points used directly via the API
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
-keep class com.google.zxing.BarcodeFormat { *; }
|
||||||
# class:
|
-keep class com.google.zxing.DecodeHintType { *; }
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
-keep class com.google.zxing.Result { *; }
|
||||||
# public *;
|
-keep class com.google.zxing.ResultMetadataType { *; }
|
||||||
#}
|
-keep class com.google.zxing.MultiFormatReader { *; }
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
# QR code reader — instantiated reflectively by MultiFormatReader
|
||||||
# debugging stack traces.
|
-keep class com.google.zxing.qrcode.QRCodeReader { *; }
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
-keep class com.google.zxing.qrcode.decoder.** { *; }
|
||||||
|
-keep class com.google.zxing.qrcode.detector.** { *; }
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# QR code generator
|
||||||
# hide the original source file name.
|
-keep class com.google.zxing.qrcode.QRCodeWriter { *; }
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
|
# Common infrastructure used by all readers
|
||||||
|
-keep class com.google.zxing.common.** { *; }
|
||||||
|
|
||||||
|
# Suppress warnings for unused format readers (1D barcodes, DataMatrix, etc.)
|
||||||
|
# that are in the jar but not needed by a wallet app
|
||||||
|
-dontwarn com.google.zxing.**
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Room 2.7.1
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep @androidx.room.Entity class *
|
||||||
|
-keep @androidx.room.Dao interface *
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
|
-dontwarn androidx.room.**
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# WorkManager 2.9.1
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep class * extends androidx.work.InputMerger { *; }
|
||||||
|
-keep class * extends androidx.work.Worker { *; }
|
||||||
|
-keep class * extends androidx.work.CoroutineWorker { *; }
|
||||||
|
-keep class * extends androidx.work.ListenableWorker { *; }
|
||||||
|
-keep class * extends androidx.work.WorkerFactory { *; }
|
||||||
|
# Keep specific classes explicitly as a safety net
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker { *; }
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker$Companion { *; }
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorkerFactory { *; }
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Kotlin Coroutines 1.11.0
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
-dontwarn kotlinx.coroutines.debug.*
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Lyricist 1.8.0
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.**Strings { *; }
|
||||||
|
-keep class com.bitcointxoko.gudariwallet.**Translations { *; }
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Timber 5.0.1
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
# Stack traces — upload mapping.txt to Play Console / Crashlytics
|
||||||
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
-renamesourcefileattribute SourceFile
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "com.bitcointxoko.gudariwallet",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "ONE_OF_MANY",
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"filterType": "ABI",
|
||||||
|
"value": "arm64-v8a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 1,
|
||||||
|
"versionName": "0.1.0",
|
||||||
|
"outputFile": "app-arm64-v8a-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File",
|
||||||
|
"baselineProfiles": [
|
||||||
|
{
|
||||||
|
"minApi": 28,
|
||||||
|
"maxApi": 30,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/1/app-arm64-v8a-release.dm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minApi": 31,
|
||||||
|
"maxApi": 2147483647,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/0/app-arm64-v8a-release.dm"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minSdkVersionForDexing": 26
|
||||||
|
}
|
||||||
@@ -10,16 +10,23 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.NFC" />
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!-- Allows resolveActivity() to see apps that handle the NWC URI scheme -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="nostr+walletconnect" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GudariWalletApplication"
|
android:name=".GudariWalletApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
|
|||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() {
|
get() {
|
||||||
val secrets = EncryptedSecretStore(this)
|
val secrets = EncryptedSecretStore(this)
|
||||||
val api = LNbitsClient.create(secrets)
|
val client = LNbitsClient(secrets)
|
||||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
|
val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
|
||||||
val paymentCache = PaymentCacheRepository(this)
|
val paymentCache = PaymentCacheRepository(this)
|
||||||
val syncStore = HistoricalSyncStore(this)
|
val syncStore = HistoricalSyncStore(this)
|
||||||
return Configuration.Builder()
|
return Configuration.Builder()
|
||||||
@@ -29,7 +29,7 @@ class GudariWalletApplication : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NotificationHelper.createChannels(this) // moved from MainActivity
|
NotificationHelper.createChannels(this)
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.nfc.NfcAdapter
|
import android.nfc.NfcAdapter
|
||||||
import android.nfc.Tag
|
import android.nfc.Tag
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -37,12 +31,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import cafe.adriel.lyricist.ProvideStrings
|
import cafe.adriel.lyricist.ProvideStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.AppLockStore
|
||||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
|
||||||
import com.bitcointxoko.gudariwallet.util.NotificationConstants
|
import com.bitcointxoko.gudariwallet.util.NotificationConstants
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var secretStore: EncryptedSecretStore
|
private lateinit var secretStore: EncryptedSecretStore
|
||||||
|
private lateinit var appLockStore: AppLockStore
|
||||||
|
|
||||||
|
private val isUnlocked = mutableStateOf(false)
|
||||||
|
|
||||||
private val vm: WalletViewModel by viewModels {
|
private val vm: WalletViewModel by viewModels {
|
||||||
WalletViewModelFactory(application)
|
WalletViewModelFactory(application)
|
||||||
@@ -90,6 +89,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
secretStore = EncryptedSecretStore(applicationContext)
|
secretStore = EncryptedSecretStore(applicationContext)
|
||||||
|
appLockStore = AppLockStore(applicationContext)
|
||||||
val syncStore = HistoricalSyncStore(applicationContext)
|
val syncStore = HistoricalSyncStore(applicationContext)
|
||||||
val credentialsState = secretStore.credentials
|
val credentialsState = secretStore.credentials
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -97,6 +97,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
initialValue = null
|
initialValue = null
|
||||||
)
|
)
|
||||||
|
val appLockEnabledState = appLockStore.appLockEnabled
|
||||||
|
.stateIn(
|
||||||
|
scope = lifecycleScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = true // safe default: locked
|
||||||
|
)
|
||||||
|
|
||||||
// Must be first — before any notification is posted
|
// Must be first — before any notification is posted
|
||||||
// NotificationHelper.createChannels(applicationContext)
|
// NotificationHelper.createChannels(applicationContext)
|
||||||
@@ -127,9 +133,25 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
val credentials by credentialsState.collectAsState()
|
val credentials by credentialsState.collectAsState()
|
||||||
|
val appLockEnabled by appLockEnabledState.collectAsState()
|
||||||
val isOnboarded = credentials?.isOnboarded == true
|
val isOnboarded = credentials?.isOnboarded == true
|
||||||
|
|
||||||
if (isOnboarded) {
|
when {
|
||||||
|
!isOnboarded -> {
|
||||||
|
OnboardingScreen(
|
||||||
|
secretStore = secretStore,
|
||||||
|
syncStore = syncStore,
|
||||||
|
appLockStore = appLockStore,
|
||||||
|
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
||||||
|
onComplete = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appLockEnabled && !isUnlocked.value -> {
|
||||||
|
BiometricLockScreen(
|
||||||
|
onUnlocked = { isUnlocked.value = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
WalletNotificationService.start(this@MainActivity)
|
WalletNotificationService.start(this@MainActivity)
|
||||||
requestNotificationPermissionIfNeeded()
|
requestNotificationPermissionIfNeeded()
|
||||||
@@ -142,18 +164,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
pendingPaymentUri = pendingPaymentUri,
|
pendingPaymentUri = pendingPaymentUri,
|
||||||
lyricist = lyricist
|
lyricist = lyricist
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
OnboardingScreen(
|
|
||||||
secretStore = secretStore,
|
|
||||||
syncStore = syncStore,
|
|
||||||
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
|
||||||
onComplete = {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
isUnlocked.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// ── NFC foreground dispatch ───────────────────────────────────────────────
|
// ── NFC foreground dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import com.google.gson.TypeAdapter
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import com.google.gson.stream.JsonReader
|
import kotlinx.serialization.KSerializer
|
||||||
import com.google.gson.stream.JsonToken
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import com.google.gson.stream.JsonWriter
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LNbits returns `comment` in PaymentExtra as either:
|
* LNbits returns `comment` in PaymentExtra as either:
|
||||||
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
|
|||||||
* - a JSON array ["Thanks!"]
|
* - a JSON array ["Thanks!"]
|
||||||
* - null
|
* - null
|
||||||
*
|
*
|
||||||
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter
|
* Mirrors the behavior of the old Gson FlexibleStringAdapter.
|
||||||
* on the field for extra safety.
|
|
||||||
*/
|
*/
|
||||||
class FlexibleStringAdapter : TypeAdapter<String?>() {
|
object FlexibleStringSerializer : KSerializer<String?> {
|
||||||
|
|
||||||
override fun write(out: JsonWriter, value: String?) {
|
override val descriptor: SerialDescriptor =
|
||||||
if (value == null) out.nullValue() else out.value(value)
|
buildClassSerialDescriptor("FlexibleString")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
override fun serialize(encoder: Encoder, value: String?) {
|
||||||
|
if (value == null) encoder.encodeNull()
|
||||||
|
else encoder.encodeString(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun read(reader: JsonReader): String? {
|
override fun deserialize(decoder: Decoder): String? {
|
||||||
return when (reader.peek()) {
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
JsonToken.NULL -> { reader.nextNull(); null }
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
JsonToken.BEGIN_ARRAY -> {
|
is JsonNull -> null
|
||||||
val parts = mutableListOf<String>()
|
is JsonPrimitive -> element.content // plain string → as-is
|
||||||
reader.beginArray()
|
is JsonArray -> element // array → join elements
|
||||||
while (reader.hasNext()) {
|
.mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
|
||||||
when (reader.peek()) {
|
.joinToString(", ")
|
||||||
JsonToken.NULL -> reader.nextNull()
|
.ifEmpty { null }
|
||||||
else -> parts.add(reader.nextString())
|
else -> null
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.endArray()
|
|
||||||
parts.joinToString(", ").ifEmpty { null }
|
|
||||||
}
|
|
||||||
else -> reader.nextString()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the app's Gson instance.
|
|
||||||
*
|
|
||||||
* All serialization/deserialization — Retrofit, SharedPreferences caches,
|
|
||||||
* and WebSocket message parsing — must use this instance so that registered
|
|
||||||
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
|
|
||||||
* everywhere.
|
|
||||||
*
|
|
||||||
* To add a new adapter: register it here once. It will automatically apply
|
|
||||||
* to every deserialization path in the app.
|
|
||||||
*/
|
|
||||||
object GsonProvider {
|
|
||||||
val gson: Gson = GsonBuilder()
|
|
||||||
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the app's JSON instance.
|
||||||
|
*
|
||||||
|
* All serialization/deserialization — Retrofit, DataStore caches,
|
||||||
|
* and WebSocket message parsing — must use this instance so that
|
||||||
|
* configuration is applied consistently everywhere.
|
||||||
|
*
|
||||||
|
* Custom type serializers (e.g. [SuccessActionSerializer],
|
||||||
|
* [FlexibleStringSerializer]) are registered directly on the model
|
||||||
|
* classes via @Serializable(with = ...) so no explicit registration
|
||||||
|
* is needed here.
|
||||||
|
*/
|
||||||
|
object JsonProvider {
|
||||||
|
val json: Json = Json {
|
||||||
|
ignoreUnknownKeys = true // tolerate extra fields from the API
|
||||||
|
isLenient = true // accept unquoted/malformed JSON from some endpoints
|
||||||
|
explicitNulls = false // omit null fields when serializing requests
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
@@ -13,7 +15,7 @@ interface LNbitsApi {
|
|||||||
/** GET /api/v1/wallet — returns balance and wallet info */
|
/** GET /api/v1/wallet — returns balance and wallet info */
|
||||||
@GET("api/v1/wallet")
|
@GET("api/v1/wallet")
|
||||||
suspend fun getWallet(
|
suspend fun getWallet(
|
||||||
@Header("X-Api-Key") apiKey: String
|
@Header("X-Api-Key") invoiceKey: String
|
||||||
): WalletResponse
|
): WalletResponse
|
||||||
|
|
||||||
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
||||||
@@ -42,10 +44,10 @@ interface LNbitsApi {
|
|||||||
): PayInvoiceResponse
|
): PayInvoiceResponse
|
||||||
|
|
||||||
/** GET /api/v1/payments — returns payment history */
|
/** GET /api/v1/payments — returns payment history */
|
||||||
@GET("api/v1/payments")
|
// @GET("api/v1/payments")
|
||||||
suspend fun getPayments(
|
// suspend fun getPayments(
|
||||||
@Header("X-Api-Key") apiKey: String
|
// @Header("X-Api-Key") apiKey: String
|
||||||
): List<WsPayment>
|
// ): List<WsPayment>
|
||||||
|
|
||||||
/** GET /api/v1/payments/paginated — returns bulk payment history */
|
/** GET /api/v1/payments/paginated — returns bulk payment history */
|
||||||
@GET("api/v1/payments/paginated")
|
@GET("api/v1/payments/paginated")
|
||||||
@@ -144,4 +146,61 @@ interface LNbitsApi {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/currencies")
|
@GET("api/v1/currencies")
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
|
||||||
|
// ── NWC Provider ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/permissions")
|
||||||
|
suspend fun getNwcPermissions(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/nwc")
|
||||||
|
suspend fun getNwcKeys(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Query("include_expired") includeExpired: Boolean = false
|
||||||
|
): List<NwcGetResponse>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun getNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String,
|
||||||
|
@Query("include_expired") includeExpired: Boolean = false,
|
||||||
|
@Query("refresh_last_used") refreshLastUsed: Boolean = false
|
||||||
|
): NwcGetResponse
|
||||||
|
|
||||||
|
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun registerNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String,
|
||||||
|
@Body request: NwcRegistrationRequest
|
||||||
|
): NwcGetResponse
|
||||||
|
|
||||||
|
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun deleteNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/pairing/{secret}")
|
||||||
|
suspend fun getNwcPairingUrl(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Path("secret") secret: String
|
||||||
|
): String
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/config")
|
||||||
|
suspend fun getNwcConfig(
|
||||||
|
@Header("X-Api-Key") adminKey: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/config/{key}")
|
||||||
|
suspend fun getNwcConfigValue(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("key") key: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@POST("nwcprovider/api/v1/config")
|
||||||
|
suspend fun setNwcConfig(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Body config: Map<String, Any>
|
||||||
|
): Map<String, Any>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,88 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.BuildConfig
|
|
||||||
import com.bitcointxoko.gudariwallet.security.SecretStore
|
import com.bitcointxoko.gudariwallet.security.SecretStore
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
object LNbitsClient {
|
class LNbitsClient(secrets: SecretStore) {
|
||||||
|
|
||||||
// Single shared client — used by both Retrofit and WebSocket
|
// Scope lives as long as this client — SupervisorJob so one failure
|
||||||
val httpClient: OkHttpClient = OkHttpClient.Builder()
|
// doesn't cancel the URL-watching coroutine.
|
||||||
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
|
// Cached in memory. Starts blank; populated as soon as DataStore emits.
|
||||||
.apply {
|
private val cachedBaseUrl = AtomicReference("")
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(HttpLoggingInterceptor().apply {
|
init {
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
scope.launch {
|
||||||
})
|
// Suspend until DataStore has emitted at least once.
|
||||||
|
// This primes cachedBaseUrl before any request can realistically fire.
|
||||||
|
val initial = secrets.credentials.first()
|
||||||
|
cachedBaseUrl.set(initial.baseUrl)
|
||||||
|
// Then keep it updated reactively for the lifetime of this client.
|
||||||
|
secrets.credentials.collect { credentials ->
|
||||||
|
cachedBaseUrl.set(credentials.baseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.build()
|
|
||||||
|
|
||||||
fun create(secrets: SecretStore): LNbitsApi {
|
|
||||||
// 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
|
val api: LNbitsApi by lazy { buildApi() }
|
||||||
// real base URL at request time, not at Retrofit construction time.
|
|
||||||
// This means credentials only need to be in DataStore by the time the
|
private fun buildApi(): LNbitsApi {
|
||||||
// first HTTP request fires, not at app startup.
|
|
||||||
val dynamicBaseUrlInterceptor = Interceptor { chain ->
|
val dynamicBaseUrlInterceptor = Interceptor { chain ->
|
||||||
val realBaseUrl = runBlocking { secrets.baseUrl() }
|
val originalRequest = chain.request()
|
||||||
.ifBlank { "https://placeholder.invalid" }
|
val originalUrl = originalRequest.url
|
||||||
.trimEnd('/')
|
|
||||||
val realHttpUrl = realBaseUrl.toHttpUrl()
|
if (originalUrl.host != "placeholder.invalid") {
|
||||||
|
return@Interceptor chain.proceed(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val raw = cachedBaseUrl.get().ifBlank { null }
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"LNbits base URL is not configured. " +
|
||||||
|
"Complete onboarding before making API calls."
|
||||||
|
)
|
||||||
|
|
||||||
|
val realHttpUrl = raw.trimEnd('/').toHttpUrl()
|
||||||
|
|
||||||
val originalUrl = chain.request().url
|
|
||||||
val newUrl = originalUrl.newBuilder()
|
val newUrl = originalUrl.newBuilder()
|
||||||
.scheme(realHttpUrl.scheme)
|
.scheme(realHttpUrl.scheme)
|
||||||
.host(realHttpUrl.host)
|
.host(realHttpUrl.host)
|
||||||
.port(realHttpUrl.port)
|
.port(realHttpUrl.port)
|
||||||
.build()
|
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
|
||||||
|
.encodedPath(
|
||||||
chain.proceed(
|
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
|
||||||
chain.request().newBuilder()
|
|
||||||
.url(newUrl)
|
|
||||||
.build()
|
|
||||||
)
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = httpClient.newBuilder()
|
val client = LNbitsHttpClient.instance.newBuilder()
|
||||||
.addInterceptor(authInterceptor)
|
|
||||||
.addInterceptor(dynamicBaseUrlInterceptor)
|
.addInterceptor(dynamicBaseUrlInterceptor)
|
||||||
.apply {
|
|
||||||
// Logging goes LAST — after URL rewrite — so logs show the real URL
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
|
.baseUrl("https://placeholder.invalid/")
|
||||||
// always overwritten by the interceptor
|
// ↑ Structurally required: Retrofit validates that baseUrl is a
|
||||||
|
// well-formed absolute URL at construction time. The interceptor
|
||||||
|
// rewrites it before any real request leaves the device.
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
|
.addConverterFactory(
|
||||||
|
JsonProvider.json.asConverterFactory(
|
||||||
|
"application/json; charset=UTF-8".toMediaType()
|
||||||
|
)
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
.create(LNbitsApi::class.java)
|
.create(LNbitsApi::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
// internal = visible within this Gradle module only.
|
||||||
|
// Intentionally not private: WalletNotificationService accesses this
|
||||||
|
// directly because Android instantiates Services via reflection —
|
||||||
|
// constructor injection is not possible. All other consumers must use
|
||||||
|
// LNbitsClient.api instead.
|
||||||
|
object LNbitsHttpClient {
|
||||||
|
internal val instance: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -1,133 +1,147 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.google.gson.TypeAdapter
|
import kotlinx.serialization.KSerializer
|
||||||
import com.google.gson.annotations.JsonAdapter
|
import kotlinx.serialization.SerialName
|
||||||
import com.google.gson.annotations.SerializedName
|
import kotlinx.serialization.Serializable
|
||||||
import com.google.gson.stream.JsonReader
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import com.google.gson.stream.JsonToken
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
import com.google.gson.stream.JsonWriter
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonEncoder
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
// ── Wallet ────────────────────────────────────────────────────────────────────
|
// ── Wallet ────────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class WalletResponse(
|
data class WalletResponse(
|
||||||
val id: String,
|
val id: String = "",
|
||||||
val name: String,
|
val name: String = "",
|
||||||
val balance: Long // millisatoshis
|
val balance: Long = 0L // millisatoshis
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Create invoice ────────────────────────────────────────────────────────────
|
// ── Create invoice ────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class CreateInvoiceRequest(
|
data class CreateInvoiceRequest(
|
||||||
val out: Boolean,
|
val out: Boolean,
|
||||||
val amount: Long, // satoshis
|
val amount: Long, // satoshis
|
||||||
val memo: String
|
val memo: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CreateInvoiceResponse(
|
data class CreateInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("payment_request") val paymentRequest: String,
|
@SerialName("payment_request") val paymentRequest: String = "",
|
||||||
@SerializedName("expiry") val expiry : String?
|
@SerialName("expiry") val expiry: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PayInvoiceRequest(
|
data class PayInvoiceRequest(
|
||||||
val out: Boolean,
|
val out: Boolean,
|
||||||
val bolt11: String
|
val bolt11: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayInvoiceResponse(
|
data class PayInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String
|
@SerialName("checking_id") val checkingId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Check payment ─────────────────────────────────────────────────────────────
|
// ── Check payment ─────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PaymentStatusResponse(
|
data class PaymentStatusResponse(
|
||||||
val paid: Boolean,
|
val paid: Boolean = false,
|
||||||
val details: PaymentDetails? = null
|
val details: PaymentDetails? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PaymentDetails(
|
data class PaymentDetails(
|
||||||
val amount: Long?,
|
val amount: Long? = null,
|
||||||
val fee: Long?,
|
val fee: Long? = null,
|
||||||
val memo: String?
|
val memo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class DecodeInvoiceRequest(
|
data class DecodeInvoiceRequest(
|
||||||
val data: String
|
val data: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class DecodeInvoiceResponse(
|
data class DecodeInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash : String? = null,
|
@SerialName("payment_hash") val paymentHash: String? = null,
|
||||||
@SerializedName("amount_msat") val amountMsat : Long? = null,
|
@SerialName("amount_msat") val amountMsat: Long? = null,
|
||||||
@SerializedName("description") val description : String? = null,
|
@SerialName("description") val description: String? = null,
|
||||||
@SerializedName("payee") val payee : String? = null,
|
@SerialName("payee") val payee: String? = null,
|
||||||
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
|
@SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class RouteHintHop(
|
data class RouteHintHop(
|
||||||
@SerializedName("public_key") val publicKey : String,
|
@SerialName("public_key") val publicKey: String = "",
|
||||||
@SerializedName("short_channel_id") val shortChannelId : String? = null,
|
@SerialName("short_channel_id") val shortChannelId: String? = null,
|
||||||
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
|
@SerialName("base_fee") val baseFeeMsat: Long = 0,
|
||||||
@SerializedName("ppm_fee") val ppmFee : Long = 0,
|
@SerialName("ppm_fee") val ppmFee: Long = 0,
|
||||||
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
|
@SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlScanResponse(
|
data class LnurlScanResponse(
|
||||||
val tag: String?,
|
val tag: String? = null,
|
||||||
// ── payRequest fields ────────────────────────────────────────────────────
|
// ── payRequest fields ─────────────────────────────────────────────────────
|
||||||
val callback: String? = null,
|
val callback: String? = null,
|
||||||
@SerializedName("minSendable") val minSendable: Long? = null,
|
@SerialName("minSendable") val minSendable: Long? = null,
|
||||||
@SerializedName("maxSendable") val maxSendable: Long? = null,
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
||||||
val metadata: String? = null,
|
val metadata: String? = null,
|
||||||
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
||||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null,
|
||||||
val bolt11: String? = null,
|
val bolt11: String? = null,
|
||||||
@SerializedName("amount_msat") val amountMsat: Long? = null,
|
@SerialName("amount_msat") val amountMsat: Long? = null,
|
||||||
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
||||||
// ── withdrawRequest fields ───────────────────────────────────────────────
|
// ── withdrawRequest fields ────────────────────────────────────────────────
|
||||||
val k1 : String? = null,
|
val k1: String? = null,
|
||||||
@SerializedName("defaultDescription")
|
@SerialName("defaultDescription") val defaultDescription: String? = null,
|
||||||
val defaultDescription: String? = null,
|
@SerialName("minWithdrawable") val minWithdrawable: Long? = null,
|
||||||
@SerializedName("minWithdrawable")
|
@SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
|
||||||
val minWithdrawable : Long? = null,
|
@SerialName("balanceCheck") val balanceCheck: String? = null,
|
||||||
@SerializedName("maxWithdrawable")
|
@SerialName("currentBalance") val currentBalance: Long? = null,
|
||||||
val maxWithdrawable : Long? = null,
|
// ── Error fields ──────────────────────────────────────────────────────────
|
||||||
@SerializedName("balanceCheck")
|
val status: String? = null, // "OK" or "ERROR"
|
||||||
val balanceCheck : String? = null,
|
val reason: String? = null // error message when status == "ERROR"
|
||||||
@SerializedName("currentBalance")
|
|
||||||
val currentBalance : Long? = null,
|
|
||||||
// ── Error fields ─────────────────────────────────────────────────────────
|
|
||||||
val status : String? = null, // "OK" or "ERROR"
|
|
||||||
val reason : String? = null // error message when status == "ERROR"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlCallbackResponse(
|
data class LnurlCallbackResponse(
|
||||||
val pr : String, // BOLT-11 invoice to pay
|
val pr: String = "",
|
||||||
val routes : List<Any>? = null,
|
val routes: List<JsonElement>? = null,
|
||||||
@SerializedName("successAction")
|
@SerialName("successAction") val successAction: SuccessAction? = null,
|
||||||
val successAction : SuccessAction? = null,
|
val status: String? = null,
|
||||||
// Error fields (server may return status=ERROR instead of pr)
|
val reason: String? = null
|
||||||
val status : String? = null,
|
|
||||||
val reason : String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlPayRes(
|
data class LnurlPayRes(
|
||||||
val tag: String?,
|
val tag: String? = null,
|
||||||
val callback: String?,
|
val callback: String? = null,
|
||||||
@SerializedName("minSendable") val minSendable: Long?,
|
@SerialName("minSendable") val minSendable: Long? = null,
|
||||||
@SerializedName("maxSendable") val maxSendable: Long?,
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
||||||
val metadata: String?,
|
val metadata: String? = null,
|
||||||
@SerializedName("commentAllowed") val commentAllowed: Int?,
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
||||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayLnurlRequest(
|
data class PayLnurlRequest(
|
||||||
val res: LnurlPayRes,
|
val res: LnurlPayRes,
|
||||||
val lnurl: String,
|
val lnurl: String,
|
||||||
@@ -135,41 +149,44 @@ data class PayLnurlRequest(
|
|||||||
val comment: String? = null
|
val comment: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayLnurlResponse(
|
data class PayLnurlResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String
|
@SerialName("checking_id") val checkingId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class WithdrawCallbackResponse(
|
data class WithdrawCallbackResponse(
|
||||||
val status : String,
|
val status: String = "",
|
||||||
val reason : String? = null
|
val reason: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlpLink(
|
data class LnurlpLink(
|
||||||
val id : String,
|
val id: String = "",
|
||||||
val wallet : String,
|
val wallet: String = "",
|
||||||
val username : String?, // the part before the @ in the lightning address
|
val username: String? = null,
|
||||||
val description : String?,
|
val description: String? = null,
|
||||||
val min : Long,
|
@Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
|
||||||
val max : Long
|
@Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// ── Payment history ───────────────────────────────────────────────────────────
|
// ── Payment history ───────────────────────────────────────────────────────────
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable
|
||||||
data class PaymentRecord(
|
data class PaymentRecord(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String,
|
@SerialName("checking_id") val checkingId: String = "",
|
||||||
@SerializedName("amount") val amountMsat: Long,
|
@SerialName("amount") val amountMsat: Long = 0L,
|
||||||
@SerializedName("fee") val feeMsat: Long,
|
@SerialName("fee") val feeMsat: Long = 0L,
|
||||||
@SerializedName("memo") val memo: String?,
|
@SerialName("memo") val memo: String? = null,
|
||||||
@SerializedName("time") val time: String,
|
@SerialName("time") val time: String = "",
|
||||||
@SerializedName("status") val status: String,
|
@SerialName("status") val status: String = "",
|
||||||
@SerializedName("bolt11") val bolt11: String?,
|
@SerialName("bolt11") val bolt11: String? = null,
|
||||||
@SerializedName("pending") val pending: Boolean,
|
@SerialName("pending") val pending: Boolean = false,
|
||||||
@SerializedName("preimage") val preimage: String?,
|
@SerialName("preimage") val preimage: String? = null,
|
||||||
@SerializedName("extra") val extra: PaymentExtra?,
|
@SerialName("extra") val extra: PaymentExtra? = null,
|
||||||
// Not from the API — populated only when loaded from Room via toDomain()
|
// Not from the API — populated only when loaded from Room via toDomain()
|
||||||
val createdAt: Long? = null,
|
val createdAt: Long? = null,
|
||||||
val pubkey: String? = null,
|
val pubkey: String? = null,
|
||||||
@@ -181,13 +198,13 @@ data class PaymentRecord(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromPayment(
|
fun fromPayment(
|
||||||
paymentHash : String,
|
paymentHash: String,
|
||||||
amountSats : Long,
|
amountSats: Long,
|
||||||
feeSats : Long?,
|
feeSats: Long?,
|
||||||
bolt11 : String?,
|
bolt11: String?,
|
||||||
memo : String?,
|
memo: String?,
|
||||||
pubkey : String?,
|
pubkey: String?,
|
||||||
alias : String?,
|
alias: String?,
|
||||||
): PaymentRecord = PaymentRecord(
|
): PaymentRecord = PaymentRecord(
|
||||||
paymentHash = paymentHash,
|
paymentHash = paymentHash,
|
||||||
checkingId = paymentHash,
|
checkingId = paymentHash,
|
||||||
@@ -207,23 +224,25 @@ data class PaymentRecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable
|
||||||
data class PaymentExtra(
|
data class PaymentExtra(
|
||||||
@SerializedName("tag") val tag: String?,
|
@SerialName("tag") val tag: String? = null,
|
||||||
@JsonAdapter(FlexibleStringAdapter::class)
|
@Serializable(with = FlexibleStringSerializer::class)
|
||||||
val comment: String?,
|
val comment: String? = null,
|
||||||
@SerializedName("success_action") val successAction: SuccessAction?,
|
@SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
|
||||||
@SerializedName("wallet_id") val walletId: String?,
|
val successAction: SuccessAction? = null,
|
||||||
@SerializedName("destination") val destination: String?,
|
@SerialName("wallet_id") val walletId: String? = null,
|
||||||
@SerializedName("expires_at") val expiresAt: String?
|
@SerialName("destination") val destination: String? = null,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable(with = SuccessActionSerializer::class)
|
||||||
data class SuccessAction(
|
data class SuccessAction(
|
||||||
@SerializedName("tag") val tag: String?,
|
@SerialName("tag") val tag: String?,
|
||||||
@SerializedName("message") val message: String?,
|
@SerialName("message") val message: String?,
|
||||||
@SerializedName("url") val url: String?,
|
@SerialName("url") val url: String?,
|
||||||
@SerializedName("description") val description: String?
|
@SerialName("description") val description: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,93 +251,158 @@ data class SuccessAction(
|
|||||||
* - a plain string "Thanks!"
|
* - a plain string "Thanks!"
|
||||||
* - null
|
* - null
|
||||||
*
|
*
|
||||||
* Gson's default deserialiser throws IllegalStateException when it sees a
|
* kotlinx.serialization's default deserializer throws when it sees a
|
||||||
* string where it expects {. This adapter handles all three cases.
|
* string where it expects {. This serializer handles all three cases.
|
||||||
*
|
*
|
||||||
* Registered centrally in [GsonProvider] — applies to all deserialization
|
* Applied via @Serializable(with = SuccessActionSerializer::class) on
|
||||||
* paths automatically.
|
* the SuccessAction class and on PaymentExtra.successAction.
|
||||||
*/
|
*/
|
||||||
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
|
object SuccessActionSerializer : KSerializer<SuccessAction?> {
|
||||||
|
|
||||||
override fun write(out: JsonWriter, value: SuccessAction?) {
|
override val descriptor: SerialDescriptor =
|
||||||
if (value == null) { out.nullValue(); return }
|
buildClassSerialDescriptor("SuccessAction")
|
||||||
out.beginObject()
|
|
||||||
out.name("tag"); out.value(value.tag)
|
override fun serialize(encoder: Encoder, value: SuccessAction?) {
|
||||||
out.name("message"); out.value(value.message)
|
val jsonEncoder = encoder as JsonEncoder
|
||||||
out.name("url"); out.value(value.url)
|
if (value == null) {
|
||||||
out.name("description"); out.value(value.description)
|
jsonEncoder.encodeJsonElement(JsonNull)
|
||||||
out.endObject()
|
} else {
|
||||||
|
jsonEncoder.encodeJsonElement(
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"tag" to JsonPrimitive(value.tag),
|
||||||
|
"message" to JsonPrimitive(value.message),
|
||||||
|
"url" to JsonPrimitive(value.url),
|
||||||
|
"description" to JsonPrimitive(value.description)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun read(reader: JsonReader): SuccessAction? {
|
override fun deserialize(decoder: Decoder): SuccessAction? {
|
||||||
return when (reader.peek()) {
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
JsonToken.NULL -> { reader.nextNull(); null }
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> {
|
||||||
// Plain string — treat as a message-type success action
|
// Plain string — treat as a message-type success action
|
||||||
JsonToken.STRING -> {
|
val text = element.jsonPrimitive.content
|
||||||
val text = reader.nextString()
|
|
||||||
if (text.isBlank()) null
|
if (text.isBlank()) null
|
||||||
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
||||||
}
|
}
|
||||||
|
is JsonObject -> {
|
||||||
// Normal object
|
SuccessAction(
|
||||||
JsonToken.BEGIN_OBJECT -> {
|
tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
|
||||||
reader.beginObject()
|
message = element.jsonObject["message"]?.jsonPrimitive?.content,
|
||||||
var tag: String? = null
|
url = element.jsonObject["url"]?.jsonPrimitive?.content,
|
||||||
var message: String? = null
|
description = element.jsonObject["description"]?.jsonPrimitive?.content
|
||||||
var url: String? = null
|
)
|
||||||
var description: String? = null
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
when (reader.nextName()) {
|
|
||||||
"tag" -> tag = reader.nextString()
|
|
||||||
"message" -> message = reader.nextString()
|
|
||||||
"url" -> url = reader.nextString()
|
|
||||||
"description" -> description = reader.nextString()
|
|
||||||
else -> reader.skipValue()
|
|
||||||
}
|
}
|
||||||
}
|
else -> null
|
||||||
reader.endObject()
|
|
||||||
SuccessAction(tag = tag, message = message, url = url, description = description)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> { reader.skipValue(); null }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PaymentDetailResponse(
|
data class PaymentDetailResponse(
|
||||||
val paid: Boolean,
|
val paid: Boolean,
|
||||||
val details: PaymentRecord?
|
val details: PaymentRecord?
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WebSocket payment message ─────────────────────────────────────────────────
|
// ── WebSocket payment message ─────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class WsPaymentMessage(
|
data class WsPaymentMessage(
|
||||||
@SerializedName("wallet_balance") val walletBalance: Long?,
|
@SerialName("wallet_balance") val walletBalance: Long? = null,
|
||||||
val payment: WsPayment?
|
val payment: WsPayment? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class WsPayment(
|
data class WsPayment(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String,
|
@SerialName("checking_id") val checkingId: String = "",
|
||||||
@SerializedName("amount") val amount: Long,
|
@SerialName("amount") val amount: Long = 0L,
|
||||||
@SerializedName("fee") val fee: Long,
|
@SerialName("fee") val fee: Long = 0L,
|
||||||
@SerializedName("memo") val memo: String?,
|
@SerialName("memo") val memo: String? = null,
|
||||||
@SerializedName("time") val time: String,
|
@SerialName("time") val time: String = "",
|
||||||
@SerializedName("status") val status: String,
|
@SerialName("status") val status: String = "",
|
||||||
@SerializedName("bolt11") val bolt11: String?,
|
@SerialName("bolt11") val bolt11: String? = null,
|
||||||
@SerializedName("pending") val pending: Boolean,
|
@SerialName("pending") val pending: Boolean = false,
|
||||||
@SerializedName("preimage") val preimage: String?,
|
@SerialName("preimage") val preimage: String? = null,
|
||||||
@SerializedName("extra") val extra: PaymentExtra?
|
@SerialName("extra") val extra: PaymentExtra? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Fiat rate ─────────────────────────────────────────────────────────────────
|
||||||
// ── Fiat rate ────────────────────────────────────────────────────────────────
|
@Serializable
|
||||||
data class FiatRateResponse(
|
data class FiatRateResponse(
|
||||||
val rate: Double, // sat to fiat rate
|
val rate: Double = 0.0, // sat to fiat rate
|
||||||
val price : Double // BTC price in the requested fiat currency
|
val price: Double = 0.0 // BTC price in the requested fiat currency
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── History sync ─────────────────────────────────────────────────────────────
|
// ── History sync ──────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PaginatedPaymentsResponse(
|
data class PaginatedPaymentsResponse(
|
||||||
@SerializedName("data") val data: List<PaymentRecord>,
|
@SerialName("data") val data: List<PaymentRecord> = emptyList(),
|
||||||
@SerializedName("total") val total: Int
|
@SerialName("total") val total: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── NWC Provider models ───────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
|
data class NwcKey(
|
||||||
|
val pubkey: String = "",
|
||||||
|
val wallet: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val expires_at: Long = 0L,
|
||||||
|
val permissions: String = "",
|
||||||
|
val created_at: Long = 0L,
|
||||||
|
val last_used: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcBudget(
|
||||||
|
val id: Int = 0,
|
||||||
|
val pubkey: String = "",
|
||||||
|
val budget_msats: Long = 0L,
|
||||||
|
val refresh_window: Int = 0,
|
||||||
|
val created_at: Long = 0L,
|
||||||
|
val used_budget_msats: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcGetResponse(
|
||||||
|
val data: NwcKey = NwcKey(),
|
||||||
|
val budgets: List<NwcBudget> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request models — no defaults needed (you control the fields, never deserializing)
|
||||||
|
@Serializable
|
||||||
|
data class NwcNewBudget(
|
||||||
|
val budget_msats: Long,
|
||||||
|
val refresh_window: Int,
|
||||||
|
val created_at: Long = System.currentTimeMillis() / 1000L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcRegistrationRequest(
|
||||||
|
val permissions: List<String>,
|
||||||
|
val description: String,
|
||||||
|
val expires_at: Long,
|
||||||
|
val budgets: List<NwcNewBudget> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes a JSON number that may arrive as either an integer (1)
|
||||||
|
* or a float (1.0) into a Long, truncating any fractional part.
|
||||||
|
*/
|
||||||
|
object LongAsDoubleSerializer : KSerializer<Long> {
|
||||||
|
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Long {
|
||||||
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
|
is JsonPrimitive -> element.content.toDouble().toLong()
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
// ── mempool.space ─────────────────────────────────────────────────────────────
|
// ── mempool.space ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class MempoolNodeResponse(
|
data class MempoolNodeResponse(
|
||||||
val alias: String?,
|
val alias: String?,
|
||||||
val public_key: String?
|
val public_key: String?
|
||||||
@@ -17,6 +19,7 @@ interface MempoolApi {
|
|||||||
|
|
||||||
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class OneMlNodeResponse(
|
data class OneMlNodeResponse(
|
||||||
val alias: String?,
|
val alias: String?,
|
||||||
val pub_key: String?
|
val pub_key: String?
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,17 +18,20 @@ class NodeAliasClient {
|
|||||||
.readTimeout(5, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
private val converter = JsonProvider.json
|
||||||
|
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
|
||||||
|
|
||||||
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
||||||
.baseUrl("https://mempool.space/")
|
.baseUrl("https://mempool.space/")
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(converter)
|
||||||
.build()
|
.build()
|
||||||
.create(MempoolApi::class.java)
|
.create(MempoolApi::class.java)
|
||||||
|
|
||||||
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
||||||
.baseUrl("https://1ml.com/")
|
.baseUrl("https://1ml.com/")
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(converter)
|
||||||
.build()
|
.build()
|
||||||
.create(OneMlApi::class.java)
|
.create(OneMlApi::class.java)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.appLockDataStore by preferencesDataStore(name = "app_lock_prefs")
|
||||||
|
|
||||||
|
class AppLockStore(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_APP_LOCK_ENABLED = booleanPreferencesKey("app_lock_enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults to true (locked) as required. */
|
||||||
|
val appLockEnabled: Flow<Boolean> = context.appLockDataStore.data
|
||||||
|
.map { prefs -> prefs[KEY_APP_LOCK_ENABLED] ?: true }
|
||||||
|
|
||||||
|
suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||||
|
context.appLockDataStore.edit { prefs ->
|
||||||
|
prefs[KEY_APP_LOCK_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class ContactRepository(app: Application) {
|
||||||
|
private val dao = AppDatabase.getInstance(app).contactDao()
|
||||||
|
|
||||||
|
// --- Observe ---
|
||||||
|
|
||||||
|
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
|
||||||
|
dao.observeAllWithAddresses()
|
||||||
|
|
||||||
|
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
||||||
|
dao.observeById(id)
|
||||||
|
|
||||||
|
/** Exposes payment-contact join summaries for feed assembly in [PaymentFeedRepository]. */
|
||||||
|
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>> =
|
||||||
|
dao.observePaymentContactSummaries()
|
||||||
|
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
suspend fun createContact(
|
||||||
|
localAlias: String,
|
||||||
|
lnAddress: String? = null,
|
||||||
|
lnUrl: String? = null,
|
||||||
|
pictureUrl: String? = null
|
||||||
|
): ContactEntity {
|
||||||
|
val contact = ContactEntity(
|
||||||
|
localAlias = localAlias,
|
||||||
|
displayName = null,
|
||||||
|
name = null,
|
||||||
|
pictureUrl = pictureUrl,
|
||||||
|
nostrPubkey = null,
|
||||||
|
nip05 = null,
|
||||||
|
about = null,
|
||||||
|
lnAddress = lnAddress
|
||||||
|
)
|
||||||
|
dao.insert(contact)
|
||||||
|
|
||||||
|
// Also add to payment_addresses table if lnAddress provided
|
||||||
|
lnAddress?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lnUrl?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LNURL.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = lnAddress == null // default only if no lnAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
suspend fun updateAlias(id: String, newAlias: String) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePicture(id: String, pictureUrl: String?) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
suspend fun addAddress(
|
||||||
|
contactId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String? = null,
|
||||||
|
makeDefault: Boolean = false
|
||||||
|
) {
|
||||||
|
if (makeDefault) dao.clearDefault(contactId, type.name)
|
||||||
|
|
||||||
|
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
|
||||||
|
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
|
||||||
|
dao.getById(contactId)?.let {
|
||||||
|
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type.name,
|
||||||
|
address = address,
|
||||||
|
label = label,
|
||||||
|
isDefault = makeDefault
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
makeDefault: Boolean
|
||||||
|
) {
|
||||||
|
val existing = dao.getAddressById(addressId) ?: return
|
||||||
|
val contactId = existing.contactId
|
||||||
|
|
||||||
|
if (makeDefault) dao.clearDefault(contactId, type.name)
|
||||||
|
|
||||||
|
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
|
||||||
|
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
|
||||||
|
dao.getById(contactId)?.let {
|
||||||
|
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.updateAddress(
|
||||||
|
addressId = addressId,
|
||||||
|
type = type.name,
|
||||||
|
address = address,
|
||||||
|
label = label,
|
||||||
|
isDefault = makeDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun removeAddress(addressId: String) {
|
||||||
|
dao.deleteAddressById(addressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
suspend fun deleteContact(id: String) {
|
||||||
|
dao.deleteById(id) // cascades to payment_addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lookup (for send flow) ---
|
||||||
|
|
||||||
|
suspend fun getById(id: String): ContactEntity? = dao.getById(id)
|
||||||
|
|
||||||
|
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
|
||||||
|
dao.findByLnAddress(lnAddress)
|
||||||
|
|
||||||
|
suspend fun findByLnurl(lnurl: String): ContactEntity? =
|
||||||
|
dao.findByAddress(lnurl)
|
||||||
|
|
||||||
|
// --- Contact-Tx link ---
|
||||||
|
|
||||||
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>> =
|
||||||
|
dao.observeContactsForPayment(paymentHash)
|
||||||
|
|
||||||
|
suspend fun linkPaymentToContact(paymentHash: String, contactId: String, role: String? = null) {
|
||||||
|
Timber.d("CONTACT [LINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.linkPaymentToContact(TxContactCrossRef(paymentHash, contactId, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String) {
|
||||||
|
Timber.d("CONTACT [UNLINK ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
dao.unlinkPaymentFromContact(paymentHash, contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
||||||
|
dao.observePaymentIdsForContact(contactId)
|
||||||
|
|
||||||
|
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
||||||
|
dao.observePaymentContactNames()
|
||||||
|
.map { list ->
|
||||||
|
list.associate { it.paymentHash to it.contactName }
|
||||||
|
.also { map ->
|
||||||
|
Timber.d(
|
||||||
|
"CONTACT [MAP EMIT ] ${map.size} entries: ${
|
||||||
|
map.entries.take(5).joinToString { "${it.key.take(8)}→${it.value}" }
|
||||||
|
}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun findPaymentHashesByContactName(query: String): List<String> =
|
||||||
|
dao.findPaymentHashesByContactName(query)
|
||||||
|
|
||||||
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String> =
|
||||||
|
dao.findPaymentHashesByContactIds(contactIds)
|
||||||
|
|
||||||
|
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity> =
|
||||||
|
dao.getContactsForPayment(paymentHash)
|
||||||
|
}
|
||||||
@@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.google.gson.Gson
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
private const val TAG = "FiatDataStore"
|
private const val TAG = "FiatDataStore"
|
||||||
@@ -27,7 +28,7 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
|
|||||||
class FiatDataStore(context: Context) {
|
class FiatDataStore(context: Context) {
|
||||||
|
|
||||||
private val dataStore = context.applicationContext.fiatDataStore
|
private val dataStore = context.applicationContext.fiatDataStore
|
||||||
private val gson = Gson()
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// ── Key factories ─────────────────────────────────────────────────────────
|
// ── Key factories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
|
|||||||
} else throw e
|
} else throw e
|
||||||
}
|
}
|
||||||
.first()
|
.first()
|
||||||
val json = prefs[currenciesKey] ?: return null
|
val jsonString = prefs[currenciesKey] ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val type = object : TypeToken<List<String>>() {}.type
|
val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
|
||||||
val list: List<String> = gson.fromJson(json, type)
|
|
||||||
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
|
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
|
||||||
list
|
list
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
|
|||||||
if (currencies.isEmpty()) return
|
if (currencies.isEmpty()) return
|
||||||
runCatching {
|
runCatching {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[currenciesKey] = gson.toJson(currencies)
|
prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
|
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val TAG = "FiatRateCache"
|
private const val TAG = "FiatRateCache"
|
||||||
@@ -13,6 +16,12 @@ private const val TAG = "FiatRateCache"
|
|||||||
*/
|
*/
|
||||||
class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
||||||
|
|
||||||
|
private val _fiatFlow = MutableStateFlow<Pair<String?, Double?>>(Pair(null, null))
|
||||||
|
|
||||||
|
/** Emits the latest (currency, satsPerUnit) pair whenever [putRate] is called. */
|
||||||
|
val fiatFlow: StateFlow<Pair<String?, Double?>> = _fiatFlow.asStateFlow()
|
||||||
|
|
||||||
|
|
||||||
// ── Rate ──────────────────────────────────────────────────────────────────
|
// ── Rate ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private var cachedRate : Double? = null
|
private var cachedRate : Double? = null
|
||||||
@@ -51,6 +60,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
cachedRate = rate
|
cachedRate = rate
|
||||||
rateLastFetchedAt = now
|
rateLastFetchedAt = now
|
||||||
cachedRateCurrency = currency
|
cachedRateCurrency = currency
|
||||||
|
_fiatFlow.value = Pair(currency, rate)
|
||||||
fiatDataStore.persistRate(currency, rate)
|
fiatDataStore.persistRate(currency, rate)
|
||||||
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
|
Timber.d("FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
|
||||||
}
|
}
|
||||||
@@ -60,6 +70,7 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
cachedRate = null
|
cachedRate = null
|
||||||
rateLastFetchedAt = 0L
|
rateLastFetchedAt = 0L
|
||||||
cachedRateCurrency = ""
|
cachedRateCurrency = ""
|
||||||
|
_fiatFlow.value = Pair(null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Currency list ─────────────────────────────────────────────────────────
|
// ── Currency list ─────────────────────────────────────────────────────────
|
||||||
@@ -91,6 +102,5 @@ class FiatRateCache(private val fiatDataStore: FiatDataStore) {
|
|||||||
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
|
Timber.d("FIAT [CURRENCIES] cached ${currencies.size} currencies")
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentRate: Double?
|
val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
||||||
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
|
|
||||||
}
|
}
|
||||||
@@ -286,4 +286,131 @@ class LNbitsWalletRepository(
|
|||||||
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
||||||
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NWC Provider ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the map of supported NWC permission names.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcPermissions(): Map<String, Any> {
|
||||||
|
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
|
||||||
|
val result = api.getNwcPermissions(secrets.invoiceKey())
|
||||||
|
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all NWC connection keys for the current wallet.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
|
||||||
|
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
|
||||||
|
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
|
||||||
|
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single NWC connection key by its public key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
includeExpired : Boolean,
|
||||||
|
refreshLastUsed : Boolean
|
||||||
|
): NwcGetResponse {
|
||||||
|
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…")
|
||||||
|
val result = api.getNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
includeExpired = includeExpired,
|
||||||
|
refreshLastUsed = refreshLastUsed
|
||||||
|
)
|
||||||
|
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers / creates a new NWC connection key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun registerNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
request : NwcRegistrationRequest
|
||||||
|
): NwcGetResponse {
|
||||||
|
Timber.d(
|
||||||
|
"%snull", "NWC [REGISTER ] pubkey=${pubkey.take(16)}… " +
|
||||||
|
"permissions=${request.permissions} "
|
||||||
|
)
|
||||||
|
val result = api.registerNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an NWC connection key by its public key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun deleteNwcKey(pubkey: String) {
|
||||||
|
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}…")
|
||||||
|
api.deleteNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey
|
||||||
|
)
|
||||||
|
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcPairingUrl(secret: String): String {
|
||||||
|
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}…")
|
||||||
|
val url = api.getNwcPairingUrl(
|
||||||
|
invoiceKey = secrets.invoiceKey(),
|
||||||
|
secret = secret
|
||||||
|
)
|
||||||
|
Timber.i("NWC [PAIRING ] pairing URL received ✓")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all extension configuration settings.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcConfig(): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG ] fetching all config settings")
|
||||||
|
val result = api.getNwcConfig(secrets.adminKey())
|
||||||
|
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single configuration value by key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG ] fetching config key='$key'")
|
||||||
|
val result = api.getNwcConfigValue(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
key = key
|
||||||
|
)
|
||||||
|
Timber.i("NWC [CONFIG ] received value for key='$key'")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets / updates one or more configuration values.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
|
||||||
|
val result = api.setNwcConfig(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
config = config
|
||||||
|
)
|
||||||
|
Timber.i("NWC [CONFIG SET] config updated ✓")
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ class LightningAddressStore(private val context: Context) {
|
|||||||
.map { prefs ->
|
.map { prefs ->
|
||||||
prefs[KEY_ADDRESS].also { address ->
|
prefs[KEY_ADDRESS].also { address ->
|
||||||
_currentAddress = address
|
_currentAddress = address
|
||||||
Timber.d("LNADDR [CACHE ] read from cache: " +
|
Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
|
||||||
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun isCacheStale(): Boolean {
|
suspend fun isCacheStale(): Boolean {
|
||||||
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
|
|||||||
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
|
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
|
||||||
val age = System.currentTimeMillis() - fetchedAt
|
val age = System.currentTimeMillis() - fetchedAt
|
||||||
val isStale = address == null || age > LNADDRESS_TTL_MS
|
val isStale = address == null || age > LNADDRESS_TTL_MS
|
||||||
Timber.d("LNADDR [TTL ] cached=${address != null} " +
|
Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
|
||||||
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " +
|
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
|
||||||
if (isStale) "→ cache stale, will fetch from API"
|
|
||||||
else "→ cache fresh, skipping API call")
|
else "→ cache fresh, skipping API call")
|
||||||
isStale
|
isStale
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||||
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
||||||
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
|
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
|
||||||
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
|
|||||||
class LnurlCacheRepository(context: Context) {
|
class LnurlCacheRepository(context: Context) {
|
||||||
|
|
||||||
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
|
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
|
||||||
private val gson = GsonProvider.gson
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// Fast in-memory path — avoids a DB round-trip for keys already loaded
|
// Fast in-memory path — avoids a DB round-trip for keys already loaded
|
||||||
// this session. Mirrors the pattern used in PaymentCacheRepository.
|
// this session. Mirrors the pattern used in PaymentCacheRepository.
|
||||||
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = deserialise(entity.responseJson) ?: run {
|
val response = deserialize(entity.responseJson) ?: run {
|
||||||
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
|
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
|
||||||
evict(key)
|
evict(key)
|
||||||
return null
|
return null
|
||||||
@@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
*/
|
*/
|
||||||
suspend fun put(key: String, response: LnurlScanResponse) {
|
suspend fun put(key: String, response: LnurlScanResponse) {
|
||||||
val disposable = response.disposable
|
val disposable = response.disposable
|
||||||
if ( disposable == true) {
|
if (disposable == true) {
|
||||||
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
|
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val entity = LnurlCacheEntity(
|
val entity = LnurlCacheEntity(
|
||||||
key = key,
|
key = key,
|
||||||
responseJson = gson.toJson(response),
|
responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
|
||||||
savedAt = System.currentTimeMillis()
|
savedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
runCatching { dao.deleteByKey(key) }
|
runCatching { dao.deleteByKey(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserialise(json: String): LnurlScanResponse? =
|
private fun deserialize(jsonString: String): LnurlScanResponse? =
|
||||||
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) }
|
runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcKey
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcBudgetEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcKeyDao
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcKeyEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class NwcCacheRepository(app: Application) {
|
||||||
|
private val dao: NwcKeyDao = AppDatabase.getInstance(app).nwcKeyDao()
|
||||||
|
|
||||||
|
// ── Observe (for ViewModels to collect) ───────────────────────────
|
||||||
|
|
||||||
|
fun observeKeys(): Flow<List<NwcKeyEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
fun observeKey(pubkey: String): Flow<NwcKeyEntity?> = dao.observeKey(pubkey)
|
||||||
|
|
||||||
|
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>> =
|
||||||
|
dao.observeBudgets(pubkey)
|
||||||
|
|
||||||
|
// ── Sync (called after network fetches) ───────────────────────────
|
||||||
|
// One-shot synchronous read — for startup population only
|
||||||
|
suspend fun getKeysOnce(): List<NwcGetResponse> =
|
||||||
|
dao.getAllKeys().map { it.toDomain().toGetResponse() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart sync for the full key list.
|
||||||
|
*
|
||||||
|
* - New keys (not yet cached) → full insert
|
||||||
|
* - Existing keys → update last_used only (all other fields are immutable)
|
||||||
|
* - Keys present in cache but absent from server response → evict
|
||||||
|
*
|
||||||
|
* Never overwrites immutable fields on existing rows.
|
||||||
|
*/
|
||||||
|
suspend fun syncKeys(responses: List<NwcGetResponse>) {
|
||||||
|
val incoming = responses.associateBy { it.data.pubkey }
|
||||||
|
val cached = dao.getAllPubkeys().toSet()
|
||||||
|
|
||||||
|
dao.syncTransaction {
|
||||||
|
val newPubkeys = incoming.keys - cached
|
||||||
|
newPubkeys.forEach { pubkey ->
|
||||||
|
val response = incoming.getValue(pubkey)
|
||||||
|
dao.insertKey(response.data.toEntity())
|
||||||
|
dao.insertBudgets(response.budgets.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingPubkeys = incoming.keys intersect cached
|
||||||
|
existingPubkeys.forEach { pubkey ->
|
||||||
|
dao.updateLastUsed(pubkey, incoming.getValue(pubkey).data.last_used)
|
||||||
|
}
|
||||||
|
|
||||||
|
val removedPubkeys = cached - incoming.keys
|
||||||
|
if (removedPubkeys.isNotEmpty()) {
|
||||||
|
dao.deleteKeys(removedPubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single key response into the cache.
|
||||||
|
* Used after registerNwcKey() — inserts the new key exactly once.
|
||||||
|
* IGNORE conflict strategy on the DAO ensures this is a no-op if
|
||||||
|
* somehow called twice for the same pubkey.
|
||||||
|
*/
|
||||||
|
suspend fun saveKey(response: NwcGetResponse) {
|
||||||
|
dao.insertKey(response.data.toEntity())
|
||||||
|
dao.insertBudgets(response.budgets.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only last_used for a single key.
|
||||||
|
* Called after getNwcKey(refreshLastUsed = true) on the detail screen.
|
||||||
|
*/
|
||||||
|
suspend fun refreshLastUsed(pubkey: String, lastUsed: Long) {
|
||||||
|
dao.updateLastUsed(pubkey, lastUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a key from the cache.
|
||||||
|
* Called after a successful deleteNwcKey() network call.
|
||||||
|
* CASCADE on the FK removes associated budgets automatically.
|
||||||
|
*/
|
||||||
|
suspend fun deleteKey(pubkey: String) {
|
||||||
|
dao.deleteKey(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mappers ───────────────────────────────────────────────────────────
|
||||||
|
// Kept in this file — they exist solely to serve NwcCacheRepository.
|
||||||
|
// If mappers grow (e.g. reverse domain→entity direction is needed),
|
||||||
|
// extract to NwcEntityMappers.kt.
|
||||||
|
|
||||||
|
private fun NwcKey.toEntity(): NwcKeyEntity = NwcKeyEntity(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expiresAt = expires_at,
|
||||||
|
permissions = permissions,
|
||||||
|
createdAt = created_at,
|
||||||
|
lastUsed = last_used,
|
||||||
|
cachedAt = System.currentTimeMillis() / 1000L
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NwcBudget.toEntity(): NwcBudgetEntity = NwcBudgetEntity(
|
||||||
|
id = id,
|
||||||
|
pubkey = pubkey,
|
||||||
|
budgetMsats = budget_msats,
|
||||||
|
refreshWindow = refresh_window,
|
||||||
|
createdAt = created_at,
|
||||||
|
usedBudgetMsats = used_budget_msats
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcKeyEntity.toDomain(): NwcKey = NwcKey(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expires_at = expiresAt,
|
||||||
|
permissions = permissions,
|
||||||
|
created_at = createdAt,
|
||||||
|
last_used = lastUsed
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcBudgetEntity.toDomain(): NwcBudget = NwcBudget(
|
||||||
|
id = id,
|
||||||
|
pubkey = pubkey,
|
||||||
|
budget_msats = budgetMsats,
|
||||||
|
refresh_window = refreshWindow,
|
||||||
|
created_at = createdAt,
|
||||||
|
used_budget_msats = usedBudgetMsats
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcKey.toGetResponse() = NwcGetResponse(data = this, budgets = emptyList())
|
||||||
|
|
||||||
@@ -10,6 +10,9 @@ import com.bitcointxoko.gudariwallet.data.db.toEntity
|
|||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentFilter
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
import com.bitcointxoko.gudariwallet.ui.history.StatusFilter
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
|
||||||
|
|
||||||
private const val TAG = "PaymentCacheRepository"
|
private const val TAG = "PaymentCacheRepository"
|
||||||
|
|
||||||
@@ -58,13 +61,33 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
if (payments.isEmpty()) return
|
if (payments.isEmpty()) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
runCatching {
|
runCatching {
|
||||||
dao.insertAll(payments.map { it.toEntity(now) })
|
payments.forEach { payment ->
|
||||||
|
val e = payment.toEntity(now)
|
||||||
|
Timber.d("PAYMENTS [MERGE ] checkingId=${e.checkingId} paymentHash=${e.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists (no DELETE, no CASCADE)
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
Timber.d("PAYMENTS [MERGE ] upserted ${payments.size} payments (total: ${dao.count()})")
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
Timber.w("PAYMENTS [MERGE ERR] ${it.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Persist the first page of payments to the database. */
|
/** Persist the first page of payments to the database. */
|
||||||
suspend fun savePayments(payments: List<PaymentRecord>) {
|
suspend fun savePayments(payments: List<PaymentRecord>) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -109,12 +132,14 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
minCreatedAt = filter.minCreatedAt,
|
minCreatedAt = filter.minCreatedAt,
|
||||||
maxCreatedAt = filter.maxCreatedAt
|
maxCreatedAt = filter.maxCreatedAt
|
||||||
).map { it.toDomain() }.also {
|
).map { it.toDomain() }.also {
|
||||||
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
|
Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
|
||||||
"amt=${filter.minAmountSat}–${filter.maxAmountSat} " +
|
"amt=${filter.minAmountSat}–${filter.maxAmountSat} ")
|
||||||
"date=${filter.minCreatedAt}–${filter.maxCreatedAt} → ${it.size} rows")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exposes the raw payment stream for feed assembly in [PaymentFeedRepository]. */
|
||||||
|
fun observeAll(): Flow<List<PaymentRecordEntity>> = dao.observeAll()
|
||||||
|
|
||||||
// ── Payment detail ────────────────────────────────────────────────────────
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns a cached detail response, or null if not yet fetched this session. */
|
/** Returns a cached detail response, or null if not yet fetched this session. */
|
||||||
@@ -131,8 +156,24 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
|
|
||||||
/** Upsert PaymentRecord to database. */
|
/** Upsert PaymentRecord to database. */
|
||||||
suspend fun upsertPayment(payment: PaymentRecord) {
|
suspend fun upsertPayment(payment: PaymentRecord) {
|
||||||
val entity = payment.toEntity(savedAt = System.currentTimeMillis())
|
val e = payment.toEntity(savedAt = System.currentTimeMillis())
|
||||||
dao.insert(entity)
|
Timber.d("PAYMENTS [UPSERT ] checkingId=${payment.checkingId} paymentHash=${payment.paymentHash}")
|
||||||
|
dao.insertIgnore(e) // no-op if row exists — no DELETE, no CASCADE
|
||||||
|
dao.updateSafe( // always patches server-owned fields in-place
|
||||||
|
checkingId = e.checkingId,
|
||||||
|
paymentHash = e.paymentHash,
|
||||||
|
amountMsat = e.amountMsat,
|
||||||
|
feeMsat = e.feeMsat,
|
||||||
|
memo = e.memo,
|
||||||
|
time = e.time,
|
||||||
|
createdAt = e.createdAt,
|
||||||
|
status = e.status,
|
||||||
|
bolt11 = e.bolt11,
|
||||||
|
pending = e.pending,
|
||||||
|
preimage = e.preimage,
|
||||||
|
extra = e.extra,
|
||||||
|
savedAt = e.savedAt
|
||||||
|
)
|
||||||
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
Timber.d("PAYMENTS [UPSERT ] ${payment.checkingId}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,4 +201,34 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
dao.deleteAll()
|
dao.deleteAll()
|
||||||
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch payments by checkingId with the full PaymentFilter applied.
|
||||||
|
* Used by HistoryViewModel for contact name search supplement and
|
||||||
|
* contact ID filter. Amount/date/status are applied in SQL;
|
||||||
|
* direction and type are handled in-memory afterwards (same as queryAll).
|
||||||
|
*/
|
||||||
|
suspend fun queryByPaymentHashes(
|
||||||
|
paymentHashes : List<String>,
|
||||||
|
filter : PaymentFilter
|
||||||
|
): List<PaymentRecord> {
|
||||||
|
if (paymentHashes.isEmpty()) return emptyList()
|
||||||
|
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
|
||||||
|
filter.statuses.flatMap { s ->
|
||||||
|
when (s) {
|
||||||
|
StatusFilter.COMPLETED -> listOf("success", "complete", "paid")
|
||||||
|
StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight")
|
||||||
|
StatusFilter.FAILED -> listOf("failed", "error", "expired")
|
||||||
|
}
|
||||||
|
}.joinToString(",")
|
||||||
|
}
|
||||||
|
return dao.getByPaymentHashesFiltered(
|
||||||
|
paymentHashes = paymentHashes,
|
||||||
|
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
|
||||||
|
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
|
||||||
|
minCreatedAt = filter.minCreatedAt,
|
||||||
|
maxCreatedAt = filter.maxCreatedAt,
|
||||||
|
statusPattern = statusPattern
|
||||||
|
).map { it.toDomain() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
class PaymentFeedRepository(
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
|
private val nodeAliasRepository : NodeAliasRepository,
|
||||||
|
private val fiatVm : FiatViewModel
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeFeedItems(): Flow<List<PaymentFeedItem>> = combine(
|
||||||
|
paymentCache.observeAll(),
|
||||||
|
contactRepo.observePaymentContactSummaries(),
|
||||||
|
nodeAliasRepository.aliases,
|
||||||
|
fiatVm.fiatFlow
|
||||||
|
) { payments: List<PaymentRecordEntity>, contactSummaries, aliasMap, fiatRate ->
|
||||||
|
val fiatCurrency = fiatRate.first
|
||||||
|
val fiatSatsPerUnit = fiatRate.second
|
||||||
|
|
||||||
|
val contactsByPayment: Map<String, List<PaymentContactSummary>> =
|
||||||
|
contactSummaries.groupBy { it.paymentHash }
|
||||||
|
|
||||||
|
payments.map { entity ->
|
||||||
|
val linked: List<ContactSummary> = contactsByPayment[entity.paymentHash]
|
||||||
|
?.map { ContactSummary(id = it.contactId, displayName = it.displayName) }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
val resolvedAlias: String? = entity.pubkey?.let { aliasMap[it] }
|
||||||
|
|
||||||
|
entity.toPaymentFeedItem(
|
||||||
|
linkedContacts = linked,
|
||||||
|
resolvedAlias = resolvedAlias,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
|
||||||
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
|
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
@@ -41,4 +43,20 @@ interface WalletRepository {
|
|||||||
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||||
suspend fun getFiatRate(currency: String): Double
|
suspend fun getFiatRate(currency: String): Double
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
suspend fun getNwcPermissions(): Map<String, Any>
|
||||||
|
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
|
||||||
|
suspend fun getNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
includeExpired : Boolean = false,
|
||||||
|
refreshLastUsed : Boolean = false
|
||||||
|
): NwcGetResponse
|
||||||
|
suspend fun registerNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
request : NwcRegistrationRequest
|
||||||
|
): NwcGetResponse
|
||||||
|
suspend fun deleteNwcKey(pubkey: String)
|
||||||
|
suspend fun getNwcPairingUrl(secret: String): String
|
||||||
|
suspend fun getNwcConfig(): Map<String, Any>
|
||||||
|
suspend fun getNwcConfigValue(key: String): Map<String, Any>
|
||||||
|
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,13 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
PaymentRecordEntity::class,
|
PaymentRecordEntity::class,
|
||||||
LnurlCacheEntity::class,
|
LnurlCacheEntity::class,
|
||||||
NodeAliasCacheEntity::class,
|
NodeAliasCacheEntity::class,
|
||||||
|
NwcKeyEntity::class,
|
||||||
|
NwcBudgetEntity::class,
|
||||||
|
ContactEntity::class,
|
||||||
|
PaymentAddressEntity::class,
|
||||||
|
TxContactCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 6,
|
version = 10,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(PaymentConverters::class)
|
@TypeConverters(PaymentConverters::class)
|
||||||
@@ -23,6 +28,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun paymentDao(): PaymentDao
|
abstract fun paymentDao(): PaymentDao
|
||||||
abstract fun lnurlCacheDao(): LnurlCacheDao
|
abstract fun lnurlCacheDao(): LnurlCacheDao
|
||||||
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
||||||
|
abstract fun nwcKeyDao(): NwcKeyDao
|
||||||
|
abstract fun contactDao(): ContactDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: AppDatabase? = null
|
@Volatile private var INSTANCE: AppDatabase? = null
|
||||||
@@ -86,6 +93,132 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nwc_keys (
|
||||||
|
pubkey TEXT NOT NULL PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used INTEGER NOT NULL,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nwc_budgets (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
budget_msats INTEGER NOT NULL,
|
||||||
|
refresh_window INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
used_budget_msats INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(pubkey) REFERENCES nwc_keys(pubkey)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_nwc_budgets_pubkey
|
||||||
|
ON nwc_budgets(pubkey)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
localAlias TEXT,
|
||||||
|
displayName TEXT,
|
||||||
|
name TEXT,
|
||||||
|
pictureUrl TEXT,
|
||||||
|
nostrPubkey TEXT,
|
||||||
|
nip05 TEXT,
|
||||||
|
about TEXT,
|
||||||
|
lnAddress TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
|
||||||
|
ON contacts(nostrPubkey)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
|
||||||
|
ON contacts(lnAddress)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_addresses (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
isDefault INTEGER NOT NULL DEFAULT 0,
|
||||||
|
addedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
|
||||||
|
ON payment_addresses(contactId)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_9_10 = object : Migration(9, 9 + 1) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// 1. Create replacement table — same schema minus the payment_records FK
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links_new (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
// 2. Copy existing rows
|
||||||
|
database.execSQL("""
|
||||||
|
INSERT INTO tx_contact_links_new (checkingId, contactId, role)
|
||||||
|
SELECT checkingId, contactId, role FROM tx_contact_links
|
||||||
|
""")
|
||||||
|
// 3. Swap tables
|
||||||
|
database.execSQL("DROP TABLE tx_contact_links")
|
||||||
|
database.execSQL("ALTER TABLE tx_contact_links_new RENAME TO tx_contact_links")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase =
|
fun getInstance(context: Context): AppDatabase =
|
||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -93,7 +226,17 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"gudari_wallet.db"
|
"gudari_wallet.db"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
.addMigrations(
|
||||||
|
MIGRATION_1_2,
|
||||||
|
MIGRATION_2_3,
|
||||||
|
MIGRATION_3_4,
|
||||||
|
MIGRATION_4_5,
|
||||||
|
MIGRATION_5_6,
|
||||||
|
MIGRATION_6_7,
|
||||||
|
MIGRATION_7_8,
|
||||||
|
MIGRATION_8_9,
|
||||||
|
MIGRATION_9_10
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
data class ContactWithAddresses(
|
||||||
|
@Embedded val contact: ContactEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "id",
|
||||||
|
entityColumn = "contactId"
|
||||||
|
)
|
||||||
|
val addresses: List<PaymentAddressEntity>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Flat projection used by PaymentFeedRepository to build ContactSummary lists. */
|
||||||
|
data class PaymentContactSummary(
|
||||||
|
val paymentHash: String,
|
||||||
|
val contactId: String,
|
||||||
|
val displayName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ContactDao {
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAll(): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id")
|
||||||
|
fun observeById(id: String): Flow<ContactWithAddresses?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun getById(id: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
|
||||||
|
suspend fun findByAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
|
||||||
|
suspend fun findByLnAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
|
||||||
|
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insert(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM contacts WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_addresses
|
||||||
|
SET type = :type,
|
||||||
|
address = :address,
|
||||||
|
label = :label,
|
||||||
|
isDefault = :isDefault
|
||||||
|
WHERE id = :addressId
|
||||||
|
""")
|
||||||
|
suspend fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: String,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
isDefault: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_addresses WHERE id = :addressId")
|
||||||
|
suspend fun getAddressById(addressId: String): PaymentAddressEntity?
|
||||||
|
|
||||||
|
@Query("DELETE FROM payment_addresses WHERE id = :id")
|
||||||
|
suspend fun deleteAddressById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
|
||||||
|
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
|
||||||
|
|
||||||
|
// Clear the isDefault flag for a type before setting a new default
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_addresses
|
||||||
|
SET isDefault = 0
|
||||||
|
WHERE contactId = :contactId AND type = :type
|
||||||
|
""")
|
||||||
|
suspend fun clearDefault(contactId: String, type: String)
|
||||||
|
|
||||||
|
// Insert a link
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
||||||
|
|
||||||
|
// Delete a link
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash AND contactId = :contactId")
|
||||||
|
suspend fun unlinkPaymentFromContact(paymentHash: String, contactId: String)
|
||||||
|
|
||||||
|
// Delete all contact links for a payment
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE paymentHash = :paymentHash")
|
||||||
|
suspend fun clearContactsForPayment(paymentHash: String)
|
||||||
|
|
||||||
|
// Get all contacts linked to a payment
|
||||||
|
@Query("""
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
||||||
|
WHERE tcl.paymentHash = :paymentHash
|
||||||
|
""")
|
||||||
|
fun observeContactsForPayment(paymentHash: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
// Get all payments linked to a contact
|
||||||
|
@Query("""
|
||||||
|
SELECT paymentHash FROM tx_contact_links
|
||||||
|
WHERE contactId = :contactId
|
||||||
|
ORDER BY createdAt DESC
|
||||||
|
""")
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
||||||
|
|
||||||
|
// Returns all (paymentHash, displayName) pairs that have a linked contact,
|
||||||
|
// so HistoryViewModel can build its lookup map in a single reactive query.
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.paymentHash AS paymentHash,
|
||||||
|
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
||||||
|
FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
""")
|
||||||
|
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
||||||
|
|
||||||
|
// Returns all (paymentHash, contactId, displayName) triples — used by
|
||||||
|
// PaymentFeedRepository to build full ContactSummary lists per payment.
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.paymentHash AS paymentHash,
|
||||||
|
c.id AS contactId,
|
||||||
|
COALESCE(c.localAlias, c.displayName, c.name) AS displayName
|
||||||
|
FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
""")
|
||||||
|
fun observePaymentContactSummaries(): Flow<List<PaymentContactSummary>>
|
||||||
|
|
||||||
|
// For text search matching contact names — returns paymentHashes of matched payments
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.paymentHash FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
||||||
|
""")
|
||||||
|
suspend fun findPaymentHashesByContactName(query: String): List<String>
|
||||||
|
|
||||||
|
// For filter-by-contact — returns paymentHashs for specific contact IDs
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT paymentHash FROM tx_contact_links
|
||||||
|
WHERE contactId IN (:contactIds)
|
||||||
|
""")
|
||||||
|
suspend fun findPaymentHashesByContactIds(contactIds: List<String>): List<String>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
INNER JOIN tx_contact_links x ON c.id = x.contactId
|
||||||
|
WHERE x.paymentHash = :paymentHash
|
||||||
|
""")
|
||||||
|
suspend fun getContactsForPayment(paymentHash: String): List<ContactEntity>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "contacts",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
|
||||||
|
Index(value = ["lnAddress"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ContactEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
// --- Display identity ---
|
||||||
|
val localAlias: String?, // user-set name — highest display priority
|
||||||
|
val displayName: String?, // future: from nostr kind 0 display_name
|
||||||
|
val name: String?, // future: from nostr kind 0 name
|
||||||
|
val pictureUrl: String?, // future: from nostr kind 0 picture
|
||||||
|
|
||||||
|
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
|
||||||
|
val nostrPubkey: String?, // 32-byte hex; unique index
|
||||||
|
val nip05: String?, // e.g. user@domain.com
|
||||||
|
val about: String?, // bio / notes
|
||||||
|
|
||||||
|
// --- Payment fast-access (denormalized for single-row lookup) ---
|
||||||
|
val lnAddress: String?, // primary Lightning Address (lud16)
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "nwc_budgets",
|
||||||
|
foreignKeys = [ForeignKey(
|
||||||
|
entity = NwcKeyEntity::class,
|
||||||
|
parentColumns = ["pubkey"],
|
||||||
|
childColumns = ["pubkey"],
|
||||||
|
onDelete = ForeignKey.CASCADE // auto-cleans budgets when key is deleted
|
||||||
|
)],
|
||||||
|
indices = [Index("pubkey")]
|
||||||
|
)
|
||||||
|
data class NwcBudgetEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "id")
|
||||||
|
val id: Int,
|
||||||
|
@ColumnInfo(name = "pubkey")
|
||||||
|
val pubkey: String,
|
||||||
|
@ColumnInfo(name = "budget_msats")
|
||||||
|
val budgetMsats: Long,
|
||||||
|
@ColumnInfo(name = "refresh_window")
|
||||||
|
val refreshWindow: Int,
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "used_budget_msats")
|
||||||
|
val usedBudgetMsats: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface NwcKeyDao {
|
||||||
|
|
||||||
|
// ── Observe (UI layer subscriptions) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of all cached keys, newest first.
|
||||||
|
* Room re-emits automatically on any write — this is the only
|
||||||
|
* channel the list screen needs to collect.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
|
||||||
|
fun observeAll(): Flow<List<NwcKeyEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of a single key — for the detail screen.
|
||||||
|
* Emits null if the key has been evicted (e.g. after a delete).
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys WHERE pubkey = :pubkey")
|
||||||
|
fun observeKey(pubkey: String): Flow<NwcKeyEntity?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of budgets for a given key — for the detail screen.
|
||||||
|
* CASCADE delete on the parent ensures this automatically becomes
|
||||||
|
* empty when the parent key is deleted.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_budgets WHERE pubkey = :pubkey")
|
||||||
|
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>>
|
||||||
|
|
||||||
|
// ── One-shot reads (sync logic, not UI) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight membership check used by syncKeys() to determine
|
||||||
|
* which incoming keys are new vs. already cached.
|
||||||
|
* Returns only pubkeys — avoids loading full rows unnecessarily.
|
||||||
|
*/
|
||||||
|
@Query("SELECT pubkey FROM nwc_keys")
|
||||||
|
suspend fun getAllPubkeys(): List<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot full read of all cached keys — used for instant startup
|
||||||
|
* population before the Flow subscription is active.
|
||||||
|
* Same query as observeAll() but returns a plain list instead of Flow.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
|
||||||
|
suspend fun getAllKeys(): List<NwcKeyEntity>
|
||||||
|
|
||||||
|
// ── Writes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a brand-new key. IGNORE on conflict means an existing row
|
||||||
|
* is never overwritten by this path — all immutable fields are safe.
|
||||||
|
* Used exclusively when a key is first seen (creation or first sync).
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertKey(key: NwcKeyEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun syncTransaction(block: suspend () -> Unit) = block()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert budgets for a new key. Also IGNORE on conflict —
|
||||||
|
* budget fields are immutable post-creation.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertBudgets(budgets: List<NwcBudgetEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ONLY last_used — the single mutable field on a key.
|
||||||
|
* This is the only update path that should ever run post-creation.
|
||||||
|
*/
|
||||||
|
@Query("UPDATE nwc_keys SET last_used = :lastUsed WHERE pubkey = :pubkey")
|
||||||
|
suspend fun updateLastUsed(pubkey: String, lastUsed: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a key by pubkey. The CASCADE foreign key on nwc_budgets
|
||||||
|
* automatically deletes all associated budget rows — no manual
|
||||||
|
* budget cleanup needed.
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM nwc_keys WHERE pubkey = :pubkey")
|
||||||
|
suspend fun deleteKey(pubkey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a set of keys in one statement — used by syncKeys() to
|
||||||
|
* remove server-deleted keys without issuing N individual deletes.
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM nwc_keys WHERE pubkey IN (:pubkeys)")
|
||||||
|
suspend fun deleteKeys(pubkeys: Collection<String>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "nwc_keys")
|
||||||
|
data class NwcKeyEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "pubkey")
|
||||||
|
val pubkey: String,
|
||||||
|
@ColumnInfo(name = "wallet")
|
||||||
|
val wallet: String,
|
||||||
|
@ColumnInfo(name = "description")
|
||||||
|
val description: String,
|
||||||
|
@ColumnInfo(name = "expires_at")
|
||||||
|
val expiresAt: Long,
|
||||||
|
@ColumnInfo(name = "permissions")
|
||||||
|
val permissions: String, // stored as comma-separated string
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "last_used")
|
||||||
|
val lastUsed: Long,
|
||||||
|
@ColumnInfo(name = "cached_at")
|
||||||
|
val cachedAt: Long // epoch-seconds, used for staleness check if desired
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "payment_addresses",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ContactEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["contactId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [Index("contactId")]
|
||||||
|
)
|
||||||
|
data class PaymentAddressEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
val contactId: String, // FK → contacts.id
|
||||||
|
|
||||||
|
val type: String, // PaymentAddressType.name (stored as String)
|
||||||
|
val address: String, // the raw address value
|
||||||
|
val label: String? = null, // e.g. "personal", "savings"
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val addedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep as a simple enum in the same file or a separate file
|
||||||
|
enum class PaymentAddressType {
|
||||||
|
LIGHTNING_ADDRESS, // user@domain.com (lud16)
|
||||||
|
LNURL, // bech32 lnurl (lud06)
|
||||||
|
ON_CHAIN, // Bitcoin address
|
||||||
|
SILENT_PAYMENT, // BIP-352 sp1...
|
||||||
|
BOLT12_OFFER, // lno...
|
||||||
|
BIP353 // ₿user@domain.com
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
data class PaymentContactName(
|
||||||
|
val paymentHash : String,
|
||||||
|
val contactName : String
|
||||||
|
)
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
|
||||||
|
|
||||||
object PaymentConverters {
|
object PaymentConverters {
|
||||||
private val gson = GsonProvider.gson
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun fromExtra(extra: PaymentExtra?): String? =
|
fun fromExtra(extra: PaymentExtra?): String? =
|
||||||
extra?.let { gson.toJson(it) }
|
extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun toExtra(json: String?): PaymentExtra? =
|
fun toExtra(string: String?): PaymentExtra? =
|
||||||
json?.let { gson.fromJson(it, PaymentExtra::class.java) }
|
string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface PaymentDao {
|
interface PaymentDao {
|
||||||
|
|
||||||
@@ -13,10 +15,53 @@ interface PaymentDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(payment: PaymentRecordEntity)
|
suspend fun insert(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
// Safe upsert used by mergePayments only.
|
||||||
|
// IGNORE skips the row on conflict (no DELETE), then updateSafe patches
|
||||||
|
// the server-owned fields in-place — the parent row is never deleted,
|
||||||
|
// so the TxContactCrossRef CASCADE is never triggered.
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertIgnore(payment: PaymentRecordEntity)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_records SET
|
||||||
|
paymentHash = :paymentHash,
|
||||||
|
amountMsat = :amountMsat,
|
||||||
|
feeMsat = :feeMsat,
|
||||||
|
memo = :memo,
|
||||||
|
time = :time,
|
||||||
|
createdAt = :createdAt,
|
||||||
|
status = :status,
|
||||||
|
bolt11 = :bolt11,
|
||||||
|
pending = :pending,
|
||||||
|
preimage = :preimage,
|
||||||
|
extra = :extra,
|
||||||
|
savedAt = :savedAt
|
||||||
|
WHERE checkingId = :checkingId
|
||||||
|
""")
|
||||||
|
suspend fun updateSafe(
|
||||||
|
checkingId: String,
|
||||||
|
paymentHash: String,
|
||||||
|
amountMsat: Long,
|
||||||
|
feeMsat: Long,
|
||||||
|
memo: String?,
|
||||||
|
time: String,
|
||||||
|
createdAt: Long?,
|
||||||
|
status: String,
|
||||||
|
bolt11: String?,
|
||||||
|
pending: Boolean,
|
||||||
|
preimage: String?,
|
||||||
|
extra: String?,
|
||||||
|
savedAt: Long
|
||||||
|
)
|
||||||
|
|
||||||
/** Returns all rows ordered by time descending. */
|
/** Returns all rows ordered by time descending. */
|
||||||
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
@Query("SELECT * FROM payment_records ORDER BY time DESC")
|
||||||
suspend fun getAll(): List<PaymentRecordEntity>
|
suspend fun getAll(): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
/** Reactive version of [getAll] — emits a new list whenever any row changes. */
|
||||||
|
@Query("SELECT * FROM payment_records ORDER BY COALESCE(createdAt, CAST(time AS INTEGER)) DESC")
|
||||||
|
fun observeAll(): Flow<List<PaymentRecordEntity>>
|
||||||
|
|
||||||
/** A single page, ordered newest-first. */
|
/** A single page, ordered newest-first. */
|
||||||
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
|
@Query("SELECT * FROM payment_records ORDER BY time DESC LIMIT :limit OFFSET :offset")
|
||||||
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
|
suspend fun getPage(limit: Int, offset: Int): List<PaymentRecordEntity>
|
||||||
@@ -32,6 +77,30 @@ interface PaymentDao {
|
|||||||
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
||||||
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
|
||||||
|
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM payment_records
|
||||||
|
WHERE paymentHash IN (:paymentHashes)
|
||||||
|
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
|
||||||
|
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
|
||||||
|
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
|
||||||
|
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
|
||||||
|
AND (:statusPattern IS NULL OR
|
||||||
|
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
||||||
|
ORDER BY createdAt DESC, time DESC
|
||||||
|
""")
|
||||||
|
suspend fun getByPaymentHashesFiltered(
|
||||||
|
paymentHashes : List<String>,
|
||||||
|
minAmountMsat : Long? = null,
|
||||||
|
maxAmountMsat : Long? = null,
|
||||||
|
minCreatedAt : Long? = null,
|
||||||
|
maxCreatedAt : Long? = null,
|
||||||
|
statusPattern : String? = null
|
||||||
|
): List<PaymentRecordEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM payment_records")
|
@Query("DELETE FROM payment_records")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
|
||||||
|
|
||||||
private const val TAG = "PaymentMappers"
|
private const val TAG = "PaymentMappers"
|
||||||
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// ── Parse helper — runs once at write time, never again ──────────────────────
|
// ── Parse helper — runs once at write time, never again ──────────────────────
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
|
|||||||
bolt11 = bolt11,
|
bolt11 = bolt11,
|
||||||
pending = pending,
|
pending = pending,
|
||||||
preimage = preimage,
|
preimage = preimage,
|
||||||
extra = extra?.let { gson.toJson(it) },
|
extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
|
||||||
savedAt = savedAt,
|
savedAt = savedAt,
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
alias = alias
|
alias = alias
|
||||||
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
|
|||||||
bolt11 = bolt11,
|
bolt11 = bolt11,
|
||||||
pending = pending,
|
pending = pending,
|
||||||
preimage = preimage,
|
preimage = preimage,
|
||||||
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) },
|
extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
alias = alias
|
alias = alias
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "tx_contact_links",
|
||||||
|
primaryKeys = ["paymentHash", "contactId"]
|
||||||
|
)
|
||||||
|
data class TxContactCrossRef(
|
||||||
|
val paymentHash : String,
|
||||||
|
val contactId : String,
|
||||||
|
val role : String? = null,
|
||||||
|
val createdAt : Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
data class ContactSummary(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String // localAlias → displayName → name, resolved at mapping time
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
|
||||||
|
data class PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────
|
||||||
|
val id: String, // = paymentHash; stable LazyColumn key
|
||||||
|
|
||||||
|
// ── Row (PaymentRow) ─────────────────────────────────────────────
|
||||||
|
val isOutgoing: Boolean,
|
||||||
|
val amountSat: Long,
|
||||||
|
val memo: String?,
|
||||||
|
val contactName: String?, // resolved: localAlias → displayName → name
|
||||||
|
val createdAt: Long?,
|
||||||
|
val time: Long?,
|
||||||
|
val isPending: Boolean,
|
||||||
|
val isFailed: Boolean,
|
||||||
|
val fiatCurrency: String?,
|
||||||
|
val fiatSatsPerUnit: Double?,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────
|
||||||
|
val paymentHash: String, // free-text search + technical detail
|
||||||
|
val tag: String?, // type filter (bolt11, keysend, etc.)
|
||||||
|
val contactIds: Set<String>, // contact-ID filter
|
||||||
|
|
||||||
|
// ── Detail (PaymentDetailScreen) — amounts ───────────────────────
|
||||||
|
val feeSat: Long?, // shown if > 0
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────
|
||||||
|
val linkedContacts: List<ContactSummary>,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────
|
||||||
|
val comment: String?,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────
|
||||||
|
val successActionMessage: String?,
|
||||||
|
val successActionUrl: String?,
|
||||||
|
val successActionDescription: String?,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────
|
||||||
|
val preimage: String?,
|
||||||
|
val pubkey: String?,
|
||||||
|
val alias: String?, // live from NodeAliasCache, fallback to snapshot
|
||||||
|
val bolt11: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstructs a minimal PaymentRecord from a PaymentFeedItem for use as
|
||||||
|
* a fallback in PaymentDetailScreen while the detail fetch is in flight.
|
||||||
|
* Fields not stored on PaymentFeedItem (checkingId, extra deserialized) are
|
||||||
|
* set to safe defaults — they will be replaced by the enriched record once
|
||||||
|
* DetailState.Success arrives.
|
||||||
|
*/
|
||||||
|
fun PaymentFeedItem.toBaseRecord(): PaymentRecord = PaymentRecord(
|
||||||
|
checkingId = paymentHash, // best approximation — checkingId not stored on feed item
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
amountMsat = if (isOutgoing) -(amountSat * 1000L) else amountSat * 1000L,
|
||||||
|
feeMsat = (feeSat ?: 0L) * 1000L,
|
||||||
|
memo = memo,
|
||||||
|
time = time?.toString() ?: "",
|
||||||
|
createdAt = createdAt,
|
||||||
|
status = when {
|
||||||
|
isFailed -> "failed"
|
||||||
|
isPending -> "pending"
|
||||||
|
else -> "complete"
|
||||||
|
},
|
||||||
|
bolt11 = bolt11,
|
||||||
|
pending = isPending,
|
||||||
|
preimage = preimage,
|
||||||
|
extra = null, // deserialized extra not stored on PaymentFeedItem
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = alias
|
||||||
|
)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.model
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
// Placeholder preimage that LNbits returns when the real one isn't available yet
|
||||||
|
private const val PREIMAGE_PLACEHOLDER = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
// Lightweight tag/comment extraction from raw extra JSON —
|
||||||
|
// avoids full PaymentExtra deserialization on every list emission.
|
||||||
|
// Falls back gracefully if the JSON is malformed or fields are absent.
|
||||||
|
private fun extractTag(extraJson: String?): String? {
|
||||||
|
if (extraJson == null) return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(extraJson).jsonObject["tag"]?.jsonPrimitive?.content
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractComment(extraJson: String?): String? {
|
||||||
|
if (extraJson == null) return null
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(extraJson).jsonObject["comment"]?.jsonPrimitive?.content
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContactEntity → ContactSummary ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fun ContactEntity.toContactSummary(): ContactSummary = ContactSummary(
|
||||||
|
id = id,
|
||||||
|
displayName = localAlias
|
||||||
|
?: displayName
|
||||||
|
?: name
|
||||||
|
?: id // last resort — never shown as empty
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PaymentRecord → PaymentFeedItem ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── PaymentRecordEntity → PaymentFeedItem ─────────────────────────────────────
|
||||||
|
// Direct mapper — bypasses toDomain() to avoid PaymentRecord allocation
|
||||||
|
// and full PaymentExtra JSON deserialization on every feed emission.
|
||||||
|
|
||||||
|
fun PaymentRecordEntity.toPaymentFeedItem(
|
||||||
|
linkedContacts : List<ContactSummary>,
|
||||||
|
resolvedAlias : String?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
fiatSatsPerUnit : Double?
|
||||||
|
): PaymentFeedItem {
|
||||||
|
val isOutgoing = amountMsat < 0
|
||||||
|
val tag = extractTag(extra)
|
||||||
|
val comment = extractComment(extra)
|
||||||
|
|
||||||
|
return PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────────
|
||||||
|
id = paymentHash,
|
||||||
|
|
||||||
|
// ── Row ──────────────────────────────────────────────────────────────
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
amountSat = Math.abs(amountMsat) / 1000L,
|
||||||
|
memo = memo,
|
||||||
|
contactName = linkedContacts.firstOrNull()?.displayName,
|
||||||
|
createdAt = createdAt,
|
||||||
|
time = time.toLongOrNull(),
|
||||||
|
isPending = pending,
|
||||||
|
isFailed = status == "failed",
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────────
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
tag = tag,
|
||||||
|
contactIds = linkedContacts.map { it.id }.toSet(),
|
||||||
|
|
||||||
|
// ── Detail — amounts ─────────────────────────────────────────────────
|
||||||
|
feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null,
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────────
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────────
|
||||||
|
comment = comment,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────────
|
||||||
|
// Not extracted in the list mapper — PaymentDetailDelegate loads full
|
||||||
|
// extra via toDomain() when the detail screen is opened.
|
||||||
|
successActionMessage = null,
|
||||||
|
successActionUrl = null,
|
||||||
|
successActionDescription = null,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────────
|
||||||
|
preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER },
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = resolvedAlias ?: alias,
|
||||||
|
bolt11 = bolt11,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun PaymentRecord.toPaymentFeedItem(
|
||||||
|
linkedContacts: List<ContactSummary>,
|
||||||
|
resolvedAlias: String?, // from NodeAliasCache; fallback to payment.alias handled here
|
||||||
|
fiatCurrency: String?,
|
||||||
|
fiatSatsPerUnit: Double?
|
||||||
|
): PaymentFeedItem {
|
||||||
|
|
||||||
|
val isOutgoing = amountMsat < 0
|
||||||
|
|
||||||
|
return PaymentFeedItem(
|
||||||
|
|
||||||
|
// ── Identity ─────────────────────────────────────────────────────────
|
||||||
|
id = paymentHash,
|
||||||
|
|
||||||
|
// ── Row ──────────────────────────────────────────────────────────────
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
amountSat = Math.abs(amountMsat) / 1000L,
|
||||||
|
memo = memo,
|
||||||
|
contactName = linkedContacts.firstOrNull()?.displayName,
|
||||||
|
createdAt = createdAt,
|
||||||
|
time = time.toLongOrNull(),
|
||||||
|
isPending = pending,
|
||||||
|
isFailed = status == "failed",
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
|
||||||
|
// ── Filter / search keys ─────────────────────────────────────────────
|
||||||
|
paymentHash = paymentHash,
|
||||||
|
tag = extra?.tag,
|
||||||
|
contactIds = linkedContacts.map { it.id }.toSet(),
|
||||||
|
|
||||||
|
// ── Detail — amounts ─────────────────────────────────────────────────
|
||||||
|
feeSat = if (feeMsat != 0L) Math.abs(feeMsat) / 1000L else null,
|
||||||
|
|
||||||
|
// ── Detail — contacts ────────────────────────────────────────────────
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
|
||||||
|
// ── Detail — basic info ──────────────────────────────────────────────
|
||||||
|
comment = extra?.comment,
|
||||||
|
|
||||||
|
// ── Detail — LNURL success action ────────────────────────────────────
|
||||||
|
successActionMessage = extra?.successAction?.message,
|
||||||
|
successActionUrl = extra?.successAction?.url,
|
||||||
|
successActionDescription = extra?.successAction?.description,
|
||||||
|
|
||||||
|
// ── Detail — technical ───────────────────────────────────────────────
|
||||||
|
preimage = preimage?.takeIf { it != PREIMAGE_PLACEHOLDER },
|
||||||
|
pubkey = pubkey,
|
||||||
|
alias = resolvedAlias ?: alias, // live cache first, snapshot fallback
|
||||||
|
bolt11 = bolt11,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.bitcointxoko.gudariwallet.i18n
|
package com.bitcointxoko.gudariwallet.i18n
|
||||||
|
|
||||||
import cafe.adriel.lyricist.LyricistStrings
|
import cafe.adriel.lyricist.LyricistStrings
|
||||||
import kotlin.String
|
|
||||||
|
|
||||||
@LyricistStrings(languageTag = "en", default = true)
|
@LyricistStrings(languageTag = "en", default = true)
|
||||||
val EnHomeStrings = AppStrings(
|
val EnHomeStrings = AppStrings(
|
||||||
@@ -55,61 +54,8 @@ val EnHomeStrings = AppStrings(
|
|||||||
details = "Details",
|
details = "Details",
|
||||||
enterValidAmount = "Enter a valid amount",
|
enterValidAmount = "Enter a valid amount",
|
||||||
satsUnit = { sats -> "$sats sats" },
|
satsUnit = { sats -> "$sats sats" },
|
||||||
|
today = "today",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "yesterday",
|
||||||
receive = "Receive",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Error",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Lightning address QR code",
|
|
||||||
lightningAddressLabel = "Lightning Address",
|
|
||||||
shareLightningAddress = "Share Lightning Address",
|
|
||||||
lightningInvoiceQrCode = "Lightning invoice QR code",
|
|
||||||
invoiceLabel = "Invoice",
|
|
||||||
shareInvoice = "Share Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Amount (sats)",
|
|
||||||
satsRange = { min, max -> "$min – $max sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Memo (optional)",
|
|
||||||
memoPlaceholder = "What's this for?",
|
|
||||||
creatingInvoice = "Creating invoice…",
|
|
||||||
createInvoice = "Create Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Waiting…",
|
|
||||||
invoiceExpired = "Invoice expired",
|
|
||||||
newInvoice = "New Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Withdraw from",
|
|
||||||
minimumSats = { min -> "Minimum is $min sats" },
|
|
||||||
maximumSats = { max -> "Maximum is $max sats" },
|
|
||||||
withdrawButton = "Withdraw",
|
|
||||||
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "Payment Received!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Reduce brightness",
|
|
||||||
brightnessBoost = "Boost brightness",
|
|
||||||
qrShare = "Share",
|
|
||||||
qrCopy = "Copy",
|
|
||||||
qrNfcStop = "Stop NFC sharing",
|
|
||||||
qrNfcStart = "Share via NFC",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "Failed to create invoice",
|
|
||||||
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
|
|
||||||
receiveVmWithdrawFailed = "Withdraw failed",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Send",
|
send = "Send",
|
||||||
@@ -196,6 +142,113 @@ val EnHomeStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
|
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
|
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
historyTitle = "Activity",
|
historyTitle = "Activity",
|
||||||
historyClose = "Close",
|
historyClose = "Close",
|
||||||
@@ -307,51 +360,128 @@ val EnHomeStrings = AppStrings(
|
|||||||
paymentRowNoMemo = "No memo",
|
paymentRowNoMemo = "No memo",
|
||||||
paymentRowPending = "Pending",
|
paymentRowPending = "Pending",
|
||||||
paymentRowFailed = "Failed",
|
paymentRowFailed = "Failed",
|
||||||
|
),
|
||||||
|
|
||||||
onboarding = OnboardingStrings(
|
nwc = NwcStrings(
|
||||||
back = "Back",
|
back = "Back",
|
||||||
next = "Next",
|
next = "Next",
|
||||||
done = "Done",
|
done = "Done",
|
||||||
title = "Connect to LNbits",
|
cancel = "Cancel",
|
||||||
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",
|
|
||||||
schedule = "Schedule",
|
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",
|
details = "Detalles",
|
||||||
enterValidAmount = "Introduce una cantidad válida",
|
enterValidAmount = "Introduce una cantidad válida",
|
||||||
satsUnit = { sats -> "$sats sats" },
|
satsUnit = { sats -> "$sats sats" },
|
||||||
|
today = "hoy",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "ayer",
|
||||||
receive = "Recibir",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Error",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Código QR de dirección Lightning",
|
|
||||||
lightningAddressLabel = "Dirección Lightning",
|
|
||||||
shareLightningAddress = "Compartir dirección Lightning",
|
|
||||||
lightningInvoiceQrCode = "Código QR de factura Lightning",
|
|
||||||
invoiceLabel = "Factura",
|
|
||||||
shareInvoice = "Compartir factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Cantidad (sats)",
|
|
||||||
satsRange = { min, max -> "$min – $max sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Nota (opcional)",
|
|
||||||
memoPlaceholder = "¿Para qué es esto?",
|
|
||||||
creatingInvoice = "Creando factura…",
|
|
||||||
createInvoice = "Crear factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Esperando…",
|
|
||||||
invoiceExpired = "Factura expirada",
|
|
||||||
newInvoice = "Nueva factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Retirar de",
|
|
||||||
minimumSats = { min -> "Mínimo es $min sats" },
|
|
||||||
maximumSats = { max -> "Máximo es $max sats" },
|
|
||||||
withdrawButton = "Retirar",
|
|
||||||
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "¡Pago recibido!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Reducir brillo",
|
|
||||||
brightnessBoost = "Aumentar brillo",
|
|
||||||
qrShare = "Compartir",
|
|
||||||
qrCopy = "Copiar",
|
|
||||||
qrNfcStop = "Detener NFC",
|
|
||||||
qrNfcStart = "Compartir por NFC",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
|
|
||||||
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
|
|
||||||
receiveVmWithdrawFailed = "Error al retirar",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Enviar",
|
send = "Enviar",
|
||||||
@@ -195,7 +142,114 @@ val EsHomeStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
|
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
|
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
|
||||||
|
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
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",
|
historyTitle = "Historial",
|
||||||
historyClose = "Cerrar",
|
historyClose = "Cerrar",
|
||||||
historySearchPayments = "Buscar pagos",
|
historySearchPayments = "Buscar pagos",
|
||||||
@@ -306,51 +360,127 @@ val EsHomeStrings = AppStrings(
|
|||||||
paymentRowNoMemo = "Sin nota",
|
paymentRowNoMemo = "Sin nota",
|
||||||
paymentRowPending = "Pendiente",
|
paymentRowPending = "Pendiente",
|
||||||
paymentRowFailed = "Fallido",
|
paymentRowFailed = "Fallido",
|
||||||
|
),
|
||||||
|
|
||||||
|
nwc = NwcStrings(
|
||||||
onboarding = OnboardingStrings(
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
back = "Atrás",
|
back = "Atrás",
|
||||||
next = "Siguiente",
|
next = "Siguiente",
|
||||||
done = "Hecho",
|
done = "Listo",
|
||||||
title = "Conectar a LNbits",
|
cancel = "Cancelar",
|
||||||
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",
|
|
||||||
schedule = "Programar",
|
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",
|
details = "Xehetasunak",
|
||||||
enterValidAmount = "Sartu zenbateko baliogarri bat",
|
enterValidAmount = "Sartu zenbateko baliogarri bat",
|
||||||
satsUnit = { sats -> "$sats sat" },
|
satsUnit = { sats -> "$sats sat" },
|
||||||
|
today = "gaur",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "atzo",
|
||||||
receive = "Jaso",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Errorea",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Lightning helbidearen QR kodea",
|
|
||||||
lightningAddressLabel = "Lightning helbidea",
|
|
||||||
shareLightningAddress = "Partekatu Lightning helbidea",
|
|
||||||
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
|
|
||||||
invoiceLabel = "Faktura",
|
|
||||||
shareInvoice = "Partekatu faktura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Zenbatekoa (sat)",
|
|
||||||
satsRange = { min, max -> "$min – $max sat" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Oharra (aukerakoa)",
|
|
||||||
memoPlaceholder = "Zertarako da hau?",
|
|
||||||
creatingInvoice = "Faktura sortzen…",
|
|
||||||
createInvoice = "Sortu faktura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Zain…",
|
|
||||||
invoiceExpired = "Faktura iraungita",
|
|
||||||
newInvoice = "Faktura berria",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Atera hemendik:",
|
|
||||||
minimumSats = { min -> "Gutxienekoa $min sat da" },
|
|
||||||
maximumSats = { max -> "Gehienekoa $max sat da" },
|
|
||||||
withdrawButton = "Atera",
|
|
||||||
withdrawButtonAmount = { amount -> "Atera $amount sat" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "Ordainketa jasota!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount sat" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Murriztu distira",
|
|
||||||
brightnessBoost = "Handitu distira",
|
|
||||||
qrShare = "Partekatu",
|
|
||||||
qrCopy = "Kopiatu",
|
|
||||||
qrNfcStop = "Gelditu NFC partekatzea",
|
|
||||||
qrNfcStart = "Partekatu NFC bidez",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
|
|
||||||
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
|
|
||||||
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Bidali",
|
send = "Bidali",
|
||||||
@@ -196,6 +143,113 @@ val EuStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
|
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
|
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
historyTitle = "Historia",
|
historyTitle = "Historia",
|
||||||
historyClose = "Itxi",
|
historyClose = "Itxi",
|
||||||
@@ -306,51 +360,127 @@ val EuStrings = AppStrings(
|
|||||||
paymentRowNoMemo = "Oharrik gabe",
|
paymentRowNoMemo = "Oharrik gabe",
|
||||||
paymentRowPending = "Zain",
|
paymentRowPending = "Zain",
|
||||||
paymentRowFailed = "Huts eginda",
|
paymentRowFailed = "Huts eginda",
|
||||||
|
),
|
||||||
|
|
||||||
|
nwc = NwcStrings(
|
||||||
onboarding = OnboardingStrings(
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
back = "Atzera",
|
back = "Atzera",
|
||||||
next = "Hurrengoa",
|
next = "Hurrengoa",
|
||||||
done = "Eginda",
|
done = "Eginda",
|
||||||
title = "Konektatu LNbits-era",
|
cancel = "Utzi",
|
||||||
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",
|
|
||||||
schedule = "Programatu",
|
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 details : String,
|
||||||
val enterValidAmount : String,
|
val enterValidAmount : String,
|
||||||
val satsUnit : (sats: Long) -> String,
|
val satsUnit : (sats: Long) -> String,
|
||||||
|
val today : String,
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
val yesterday : String,
|
||||||
val receive : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
val receiveError : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
val lightningAddressQrCode : String,
|
|
||||||
val lightningAddressLabel : String,
|
|
||||||
val shareLightningAddress : String,
|
|
||||||
val lightningInvoiceQrCode : String,
|
|
||||||
val invoiceLabel : String,
|
|
||||||
val shareInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
val amountInSats : String, // text field label
|
|
||||||
val satsRange : (min: Long, max: Long) -> String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
val memoOptional : String,
|
|
||||||
val memoPlaceholder : String,
|
|
||||||
val creatingInvoice : String,
|
|
||||||
val createInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
val waiting : String,
|
|
||||||
val invoiceExpired : String,
|
|
||||||
val newInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
val withdrawFrom : String,
|
|
||||||
val minimumSats : (min: Long) -> String,
|
|
||||||
val maximumSats : (max: Long) -> String,
|
|
||||||
val withdrawButton : String, // no fixed amount
|
|
||||||
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
val paymentReceived : String,
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
val amountDisplaySats : (amount: Long) -> String,
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
val brightnessReduce : String,
|
|
||||||
val brightnessBoost : String,
|
|
||||||
val qrShare : String,
|
|
||||||
val qrCopy : String,
|
|
||||||
val qrNfcStop : String,
|
|
||||||
val qrNfcStart : String,
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
val receiveVmFailedToCreateInvoice : String,
|
|
||||||
val receiveVmWithdrawRejected : String,
|
|
||||||
val receiveVmWithdrawFailed : String,
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
val send : String,
|
val send : String,
|
||||||
@@ -204,6 +151,120 @@ data class AppStrings(
|
|||||||
val sendVmUnsupportedType : (tag: String) -> String,
|
val sendVmUnsupportedType : (tag: String) -> String,
|
||||||
val sendVmPaymentFailedRescan : String,
|
val sendVmPaymentFailedRescan : String,
|
||||||
|
|
||||||
|
val onboarding: OnboardingStrings,
|
||||||
|
val receive: ReceiveStrings,
|
||||||
|
val history: HistoryStrings,
|
||||||
|
val nwc: NwcStrings,
|
||||||
|
val lock: LockStrings
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OnboardingStrings(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val serverUrl: String,
|
||||||
|
val serverUrlHint: String,
|
||||||
|
val invoiceKey: String,
|
||||||
|
val invoiceKeySubtitle: String,
|
||||||
|
val pasteInvoiceKey: String,
|
||||||
|
val adminKey: String,
|
||||||
|
val adminKeySubtitle: String,
|
||||||
|
val pasteAdminKey: String,
|
||||||
|
val showAdminKey: String,
|
||||||
|
val hideAdminKey: String,
|
||||||
|
val connect: String,
|
||||||
|
val fieldsRequired: String,
|
||||||
|
val invalidKeyFormat: String,
|
||||||
|
val back: String,
|
||||||
|
val next: String,
|
||||||
|
val done: String,
|
||||||
|
val notificationsTitle: String,
|
||||||
|
val notificationsSubtitle: String,
|
||||||
|
val notificationsAlreadyGranted: String,
|
||||||
|
val allowNotifications: String,
|
||||||
|
val notificationsNote: String,
|
||||||
|
val notificationsAutoGranted: String,
|
||||||
|
val batteryTitle: String,
|
||||||
|
val batterySubtitle: String,
|
||||||
|
val openSettings: String,
|
||||||
|
val batteryNote: String,
|
||||||
|
val batteryAlreadyUnrestricted: String,
|
||||||
|
val paymentHistory: String,
|
||||||
|
val paymentHistorySubtitle: String,
|
||||||
|
val syncNow: String,
|
||||||
|
val skipForNow: String,
|
||||||
|
val syncScheduled: String,
|
||||||
|
val continueToApp: String,
|
||||||
|
val connecting: String,
|
||||||
|
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
|
||||||
|
val paymentsSynced: (total: Int) -> String,
|
||||||
|
val syncComplete: String,
|
||||||
|
val syncFailed: String,
|
||||||
|
val scheduleForLater: String,
|
||||||
|
val scheduleSync: String,
|
||||||
|
val schedule: String,
|
||||||
|
val cancel: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReceiveStrings(
|
||||||
|
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
||||||
|
val receive : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
||||||
|
val receiveError : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
||||||
|
val lightningAddressQrCode : String,
|
||||||
|
val lightningAddressLabel : String,
|
||||||
|
val shareLightningAddress : String,
|
||||||
|
val lightningInvoiceQrCode : String,
|
||||||
|
val invoiceLabel : String,
|
||||||
|
val shareInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
||||||
|
val amountInSats : String, // text field label
|
||||||
|
val satsRange : (min: Long, max: Long) -> String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
||||||
|
val memoOptional : String,
|
||||||
|
val memoPlaceholder : String,
|
||||||
|
val creatingInvoice : String,
|
||||||
|
val createInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
||||||
|
val waiting : String,
|
||||||
|
val invoiceExpired : String,
|
||||||
|
val newInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
||||||
|
val withdrawFrom : String,
|
||||||
|
val minimumSats : (min: Long) -> String,
|
||||||
|
val maximumSats : (max: Long) -> String,
|
||||||
|
val withdrawButton : String, // no fixed amount
|
||||||
|
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
|
||||||
|
|
||||||
|
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
||||||
|
val paymentReceived : String,
|
||||||
|
|
||||||
|
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
||||||
|
val amountDisplaySats : (amount: Long) -> String,
|
||||||
|
|
||||||
|
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
||||||
|
val brightnessReduce : String,
|
||||||
|
val brightnessBoost : String,
|
||||||
|
val qrShare : String,
|
||||||
|
val qrCopy : String,
|
||||||
|
val qrNfcStop : String,
|
||||||
|
val qrNfcStart : String,
|
||||||
|
val openInApp : String,
|
||||||
|
val noAppFound : String,
|
||||||
|
|
||||||
|
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
||||||
|
val receiveVmFailedToCreateInvoice : String,
|
||||||
|
val receiveVmWithdrawRejected : String,
|
||||||
|
val receiveVmWithdrawFailed : String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HistoryStrings(
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
val historyTitle : String,
|
val historyTitle : String,
|
||||||
val historyClose : String,
|
val historyClose : String,
|
||||||
@@ -303,52 +364,133 @@ data class AppStrings(
|
|||||||
val paymentRowNoMemo : String,
|
val paymentRowNoMemo : String,
|
||||||
val paymentRowPending : String,
|
val paymentRowPending : String,
|
||||||
val paymentRowFailed : String,
|
val paymentRowFailed : String,
|
||||||
|
|
||||||
val onboarding: OnboardingStrings
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OnboardingStrings(
|
data class NwcStrings(
|
||||||
val title: String,
|
|
||||||
val subtitle: String,
|
// ── Shared / navigation ───────────────────────────────────────────────────
|
||||||
val serverUrl: String,
|
val back : String,
|
||||||
val serverUrlHint: String,
|
val next : String,
|
||||||
val invoiceKey: String,
|
val done : String,
|
||||||
val invoiceKeySubtitle: String,
|
val cancel : String,
|
||||||
val pasteInvoiceKey: String,
|
val schedule : String,
|
||||||
val adminKey: String,
|
|
||||||
val adminKeySubtitle: String,
|
// ── Connection detail ─────────────────────────────────────────────────────
|
||||||
val pasteAdminKey: String,
|
val connectionDetailTitle : String,
|
||||||
val showAdminKey: String,
|
val connectionUnknownWallet : String,
|
||||||
val hideAdminKey: String,
|
val connectionSectionDetails : String,
|
||||||
val connect: String,
|
val connectionRowCreated : String,
|
||||||
val fieldsRequired: String,
|
val connectionRowLastUsed : String,
|
||||||
val back: String,
|
val connectionRowExpires : String,
|
||||||
val next: String,
|
val connectionRowPubkey : String,
|
||||||
val done: String,
|
val connectionNeverUsed : String,
|
||||||
val notificationsTitle: String,
|
val lastUsedToday : String, // e.g. "Last used today"
|
||||||
val notificationsSubtitle: String,
|
val lastUsedYesterday : String, // e.g. "Last used yesterday"
|
||||||
val notificationsAlreadyGranted: String,
|
val lastUsedDate : String, // e.g. "Last used %s" — %s is a formatted date
|
||||||
val allowNotifications: String,
|
val connectionNoExpiry : String,
|
||||||
val notificationsNote: String,
|
/** "Expired · {date}" */
|
||||||
val notificationsAutoGranted: String,
|
val connectionExpired : (date: String) -> String,
|
||||||
val batteryTitle: String,
|
val connectionSectionPermissions : String,
|
||||||
val batterySubtitle: String,
|
val connectionSectionBudgets : String,
|
||||||
val openSettings: String,
|
/** "{window} budget" e.g. "Daily budget" */
|
||||||
val batteryNote: String,
|
val connectionBudgetTitle : (window: String) -> String,
|
||||||
val batteryAlreadyUnrestricted: String,
|
/** "Used: {sats} sats" */
|
||||||
val paymentHistory: String,
|
val connectionBudgetUsed : (sats: Long) -> String,
|
||||||
val paymentHistorySubtitle: String,
|
/** "of {sats} sats" */
|
||||||
val syncNow: String,
|
val connectionBudgetTotal : (sats: Long) -> String,
|
||||||
val skipForNow: String,
|
val connectionRevoke : String,
|
||||||
val syncScheduled: String,
|
val connectionStatusActive : String,
|
||||||
val continueToApp: String,
|
val connectionStatusExpired : String,
|
||||||
val connecting: String,
|
|
||||||
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
|
// ── NWC permission labels ─────────────────────────────────────────────────
|
||||||
val paymentsSynced: (total: Int) -> String,
|
val permPayInvoice : String,
|
||||||
val syncComplete: String,
|
val permGetBalance : String,
|
||||||
val syncFailed: String,
|
val permGetInfo : String,
|
||||||
val scheduleForLater: String,
|
val permMakeInvoice : String,
|
||||||
val scheduleSync: String,
|
val permLookupInvoice : String,
|
||||||
val schedule: String,
|
val permListTransactions : String,
|
||||||
val cancel: String
|
val permSignMessage : String,
|
||||||
|
val permPay : String, // "Send payments"
|
||||||
|
val permInvoice : String, // "Create invoices"
|
||||||
|
val permLookup : String,
|
||||||
|
val permHistory : String,
|
||||||
|
val permBalance : String,
|
||||||
|
val permInfo : String,
|
||||||
|
val permLookupHistory : String, // "Lookup status & transaction history"
|
||||||
|
val permBalanceInfo : String, // "Read balance & account info"
|
||||||
|
val permSendPayments : String,
|
||||||
|
val permCreateInvoices : String,
|
||||||
|
val permLookupInvoiceStatus: String,
|
||||||
|
val permReadTransactionHistory: String,
|
||||||
|
val permReadWalletBalance: String,
|
||||||
|
val permReadAccountInfo: String,
|
||||||
|
val revokeDialogTitle : String,
|
||||||
|
val revokeDialogBody : String,
|
||||||
|
val revokeDialogConfirm : String,
|
||||||
|
val revokeDialogDismiss : String,
|
||||||
|
val refreshDaily : String,
|
||||||
|
val refreshWeekly : String,
|
||||||
|
val refreshMonthly : String,
|
||||||
|
val refreshYearly : String,
|
||||||
|
val refreshNever : String,
|
||||||
|
|
||||||
|
// New Connection
|
||||||
|
val newConnection : String,
|
||||||
|
val labelHint : String,
|
||||||
|
val expires : String,
|
||||||
|
val pickDate : String,
|
||||||
|
val pickTime : String,
|
||||||
|
val never : String,
|
||||||
|
val budgets : String,
|
||||||
|
val addBudget : String,
|
||||||
|
val removeBudget : String,
|
||||||
|
val permissions : String,
|
||||||
|
val unnamed : String,
|
||||||
|
val creating : String,
|
||||||
|
val createConnection : String,
|
||||||
|
val ok : String,
|
||||||
|
val sats : String,
|
||||||
|
val resets : String,
|
||||||
|
|
||||||
|
val deleteConnection : String,
|
||||||
|
val unnamedConnection : String,
|
||||||
|
val neverUsed : String,
|
||||||
|
val expired : String,
|
||||||
|
|
||||||
|
val connectionCreated : String,
|
||||||
|
val scanOrCopyHint : String,
|
||||||
|
val nwcPairingQrCode : String,
|
||||||
|
val nwcConnectionString : String,
|
||||||
|
val shareNwcConnectionString : String,
|
||||||
|
val openInApp : String,
|
||||||
|
|
||||||
|
val walletConnect : String,
|
||||||
|
val addConnection : String,
|
||||||
|
val noCompatibleAppFound : String,
|
||||||
|
val removeConnection : String,
|
||||||
|
val removeConnectionDetail: String,
|
||||||
|
val remove : String,
|
||||||
|
|
||||||
|
val noConnectionsYet : String,
|
||||||
|
val tapToAddHint : String,
|
||||||
|
val somethingWentWrong : String,
|
||||||
|
val retry : String,
|
||||||
|
|
||||||
|
val failedToLoadConnections : String,
|
||||||
|
val failedToCreateConnection : String,
|
||||||
|
val failedToDeleteConnection : String,
|
||||||
|
val failedToLoadConnection : String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LockStrings(
|
||||||
|
val appLockTitle: String,
|
||||||
|
val appLockDescription: String,
|
||||||
|
val appLockToggleLabel: String,
|
||||||
|
val appLockToggleEnabled: String,
|
||||||
|
val appLockToggleDisabled: String,
|
||||||
|
val appName: String,
|
||||||
|
val authenticateToContinue: String,
|
||||||
|
val unlockButton: String,
|
||||||
|
val biometricPromptTitle: String,
|
||||||
|
val biometricPromptSubtitle: String,
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.i18n
|
package com.bitcointxoko.gudariwallet.i18n
|
||||||
|
|
||||||
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
|
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
|
||||||
"success" -> statusSuccess
|
"success" -> history.statusSuccess
|
||||||
"pending" -> statusPending
|
"pending" -> history.statusPending
|
||||||
"failed" -> statusFailed
|
"failed" -> history.statusFailed
|
||||||
else -> statusUnknown
|
else -> history.statusUnknown
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
package com.bitcointxoko.gudariwallet.security
|
package com.bitcointxoko.gudariwallet.security
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.migrations.SharedPreferencesMigration
|
import androidx.datastore.migrations.SharedPreferencesMigration
|
||||||
import androidx.datastore.migrations.SharedPreferencesView
|
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton DataStore for encrypted credentials.
|
* Singleton DataStore for encrypted credentials.
|
||||||
@@ -27,8 +22,6 @@ import androidx.core.content.edit
|
|||||||
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
|
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
|
||||||
*/
|
*/
|
||||||
object CredentialsDataStore {
|
object CredentialsDataStore {
|
||||||
private const val TAG = "CredentialsDataStore"
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: DataStore<Credentials>? = null
|
private var instance: DataStore<Credentials>? = null
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class CredentialsSerializer(
|
|||||||
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
|
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e)
|
Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
|
||||||
throw CorruptionException("Cannot read/decrypt credentials DataStore", e)
|
throw CorruptionException("Cannot read/decrypt credentials DataStore")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +37,10 @@ class CredentialsSerializer(
|
|||||||
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
|
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
|
||||||
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
|
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
|
||||||
encryptingStream.close()
|
encryptingStream.close()
|
||||||
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
|
Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
|
||||||
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " +
|
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
|
||||||
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
|
|
||||||
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Encryption/write failed", e)
|
Timber.e("Encryption/write failed")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
|
|||||||
Timber.i("StreamingAead primitive acquired successfully")
|
Timber.i("StreamingAead primitive acquired successfully")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable", e)
|
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class NodeAliasService(
|
|||||||
repo.put(pubkey, alias)
|
repo.put(pubkey, alias)
|
||||||
|
|
||||||
// 4. Publish to StateFlow
|
// 4. Publish to StateFlow
|
||||||
_aliases.value = _aliases.value + (pubkey to alias)
|
_aliases.value += (pubkey to alias)
|
||||||
|
|
||||||
Timber.d("ALIAS [FETCHED ] $pubkey → \"$alias\"")
|
Timber.d("ALIAS [FETCHED ] $pubkey → \"$alias\"")
|
||||||
return alias
|
return alias
|
||||||
|
|||||||
+7
-6
@@ -7,8 +7,8 @@ import android.content.pm.ServiceInfo
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.bitcointxoko.gudariwallet.api.LNbitsClient
|
import com.bitcointxoko.gudariwallet.api.LNbitsHttpClient
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
|
import com.bitcointxoko.gudariwallet.api.WsPaymentMessage
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
@@ -30,6 +30,7 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "WalletNotifService"
|
private const val TAG = "WalletNotifService"
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ class WalletNotificationService : Service() {
|
|||||||
private var reconnectAttempts = 0
|
private var reconnectAttempts = 0
|
||||||
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
|
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
private val gson = GsonProvider.gson
|
private val json = JsonProvider.json
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -157,7 +158,7 @@ class WalletNotificationService : Service() {
|
|||||||
|
|
||||||
val request = Request.Builder().url(wsUrl).build()
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
|
||||||
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() {
|
webSocket = LNbitsHttpClient.instance.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
Timber.d("WebSocket connected")
|
Timber.d("WebSocket connected")
|
||||||
@@ -193,7 +194,7 @@ class WalletNotificationService : Service() {
|
|||||||
|
|
||||||
private fun handleMessage(text: String) {
|
private fun handleMessage(text: String) {
|
||||||
val msg = runCatching {
|
val msg = runCatching {
|
||||||
gson.fromJson(text, WsPaymentMessage::class.java)
|
json.decodeFromString(WsPaymentMessage.serializer(), text)
|
||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: return
|
||||||
|
|
||||||
val payment = msg.payment ?: return
|
val payment = msg.payment ?: return
|
||||||
@@ -271,7 +272,7 @@ class WalletNotificationService : Service() {
|
|||||||
|
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
reconnectJob = serviceScope.launch {
|
reconnectJob = serviceScope.launch {
|
||||||
delay(backoff)
|
delay(backoff.milliseconds)
|
||||||
openWebSocket()
|
openWebSocket()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import androidx.compose.material.icons.filled.Language // ← NEW
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.filled.Cable
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
@@ -38,6 +40,8 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
|||||||
fun HomeTab(
|
fun HomeTab(
|
||||||
vm: WalletViewModel,
|
vm: WalletViewModel,
|
||||||
onHistoryClick: () -> Unit,
|
onHistoryClick: () -> Unit,
|
||||||
|
onNwcClick : () -> Unit,
|
||||||
|
onContactsClick: () -> Unit,
|
||||||
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -50,7 +54,7 @@ fun HomeTab(
|
|||||||
|
|
||||||
val isRefreshing = balanceState is BalanceState.Loading
|
val isRefreshing = balanceState is BalanceState.Loading
|
||||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
onRefresh = { vm.refreshBalance() },
|
onRefresh = { vm.refreshBalance() },
|
||||||
@@ -174,7 +178,9 @@ fun HomeTab(
|
|||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.75f
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(2.dp))
|
Spacer(Modifier.width(2.dp))
|
||||||
Icon(
|
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 ─────────────────────────────────────────
|
// ── NEW: Language switcher composable ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ sealed class ReceiveState {
|
|||||||
data class PaymentReceived(
|
data class PaymentReceived(
|
||||||
val amountSats: Long,
|
val amountSats: Long,
|
||||||
val memo : String?,
|
val memo : String?,
|
||||||
val checkingId : String
|
val paymentHash : String
|
||||||
) : ReceiveState()
|
) : ReceiveState()
|
||||||
data class Error(val message: String) : ReceiveState()
|
data class Error(val message: String) : ReceiveState()
|
||||||
data class LnurlWithdrawReady(
|
data class LnurlWithdrawReady(
|
||||||
@@ -85,14 +85,18 @@ sealed class SendState {
|
|||||||
override val routeHintAliases : Map<String, String> = emptyMap(),
|
override val routeHintAliases : Map<String, String> = emptyMap(),
|
||||||
override val paymentHash : String,
|
override val paymentHash : String,
|
||||||
override val createdAt : Instant,
|
override val createdAt : Instant,
|
||||||
override val expiresAt : Instant
|
override val expiresAt : Instant,
|
||||||
|
val contactId : String? = null
|
||||||
) : SendState(), DecodedInvoice
|
) : SendState(), DecodedInvoice
|
||||||
data object Paying : SendState()
|
data object Paying : SendState()
|
||||||
data class PaymentSent(
|
data class PaymentSent(
|
||||||
val amountSats : Long,
|
val amountSats : Long,
|
||||||
val feeSats : Long? = null,
|
val feeSats : Long? = null,
|
||||||
val paymentHash: String,
|
val paymentHash: String,
|
||||||
val pendingRecord: PaymentRecord? = null
|
val pendingRecord: PaymentRecord? = null,
|
||||||
|
val lnurl : String? = null,
|
||||||
|
val contactId : String? = null,
|
||||||
|
val contactName : String? = null
|
||||||
) : SendState()
|
) : SendState()
|
||||||
data class Error(val message: String) : SendState()
|
data class Error(val message: String) : SendState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui
|
package com.bitcointxoko.gudariwallet.ui
|
||||||
|
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -57,16 +59,26 @@ import cafe.adriel.lyricist.Lyricist
|
|||||||
import com.bitcointxoko.gudariwallet.MainActivity
|
import com.bitcointxoko.gudariwallet.MainActivity
|
||||||
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
// ── Tab destinations ──────────────────────────────────────────────────────────
|
// ── Tab destinations ──────────────────────────────────────────────────────────
|
||||||
// Note: label and icon are now resolved at the call site via LocalAppStrings,
|
// Note: label and icon are now resolved at the call site via LocalAppStrings,
|
||||||
@@ -80,8 +92,14 @@ sealed class TabItem(val route: String, val icon: ImageVector) {
|
|||||||
private const val ROUTE_HOME = "home"
|
private const val ROUTE_HOME = "home"
|
||||||
private const val ROUTE_SCANNER = "scanner"
|
private const val ROUTE_SCANNER = "scanner"
|
||||||
private const val ROUTE_HISTORY = "history"
|
private const val ROUTE_HISTORY = "history"
|
||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
|
||||||
|
private const val ROUTE_NWC = "nwc"
|
||||||
|
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||||
|
private const val ROUTE_CONTACTS = "contacts"
|
||||||
|
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||||
|
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
vm : WalletViewModel,
|
vm : WalletViewModel,
|
||||||
@@ -115,11 +133,25 @@ fun WalletScreen(
|
|||||||
repo = vm.repo,
|
repo = vm.repo,
|
||||||
aliasRepo = vm.aliasRepo,
|
aliasRepo = vm.aliasRepo,
|
||||||
paymentCache = vm.paymentCache,
|
paymentCache = vm.paymentCache,
|
||||||
|
contactRepo = vm.contactRepo,
|
||||||
|
feedRepository = PaymentFeedRepository(
|
||||||
|
paymentCache = vm.paymentCache,
|
||||||
|
contactRepo = vm.contactRepo,
|
||||||
|
nodeAliasRepository = vm.aliasRepo,
|
||||||
|
fiatVm = vm.fiatVm
|
||||||
|
),
|
||||||
fiatCurrency = vm.selectedCurrency,
|
fiatCurrency = vm.selectedCurrency,
|
||||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||||
|
val nwcVm: NwcViewModel = viewModel(
|
||||||
|
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
|
||||||
|
)
|
||||||
|
val contactsVm: ContactsViewModel = viewModel(
|
||||||
|
factory = ContactsViewModel.Factory(vm.contactRepo)
|
||||||
|
)
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
@@ -127,10 +159,18 @@ fun WalletScreen(
|
|||||||
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||||
&& currentRoute != ROUTE_SCANNER
|
&& currentRoute != ROUTE_SCANNER
|
||||||
&& currentRoute != ROUTE_HISTORY
|
&& currentRoute != ROUTE_HISTORY
|
||||||
|
&& currentRoute != ROUTE_NWC
|
||||||
|
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
||||||
|
&& currentRoute != ROUTE_CONTACTS
|
||||||
|
&& currentRoute != ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|| currentRoute == ROUTE_SCANNER
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|
|| currentRoute == ROUTE_NWC
|
||||||
|
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
||||||
|
|| currentRoute == ROUTE_CONTACTS
|
||||||
|
|| currentRoute == ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
// ── Notification tap ──────────────────────────────────────────────────────
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
val hash = pendingPaymentHash.value
|
val hash = pendingPaymentHash.value
|
||||||
@@ -192,7 +232,7 @@ fun WalletScreen(
|
|||||||
|
|
||||||
LaunchedEffect(clipboardOffer) {
|
LaunchedEffect(clipboardOffer) {
|
||||||
if (clipboardOffer is ClipboardOfferState.Detected) {
|
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||||
delay(8_000)
|
delay(8_000.milliseconds)
|
||||||
vm.dismissClipboardOffer()
|
vm.dismissClipboardOffer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +242,7 @@ fun WalletScreen(
|
|||||||
|
|
||||||
LaunchedEffect(nfcOffer) {
|
LaunchedEffect(nfcOffer) {
|
||||||
if (nfcOffer is NfcOfferState.Detected) {
|
if (nfcOffer is NfcOfferState.Detected) {
|
||||||
delay(8_000)
|
delay(8_000.milliseconds)
|
||||||
nfcVm.dismissNfcOffer()
|
nfcVm.dismissNfcOffer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +362,14 @@ fun WalletScreen(
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onNwcClick = {
|
||||||
|
navController.navigate(ROUTE_NWC) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContactsClick = {
|
||||||
|
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
|
||||||
|
},
|
||||||
lyricist = lyricist
|
lyricist = lyricist
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,8 +377,8 @@ fun WalletScreen(
|
|||||||
ReceiveScreen(
|
ReceiveScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
nfcVm = nfcVm,
|
nfcVm = nfcVm,
|
||||||
onNavigateToPaymentDetail = { checkingId ->
|
onNavigateToPaymentDetail = { paymentHash ->
|
||||||
navController.navigate("payment_detail/$checkingId")
|
navController.navigate("payment_detail/$paymentHash")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -338,9 +386,14 @@ fun WalletScreen(
|
|||||||
SendScreen(
|
SendScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
nfcVm = nfcVm,
|
nfcVm = nfcVm,
|
||||||
onViewDetails = { checkingId, record ->
|
onViewDetails = { paymentHash, record ->
|
||||||
historyVm.primePayment(record)
|
historyVm.primePayment(record)
|
||||||
navController.navigate("payment_detail/$checkingId") {
|
navController.navigate("payment_detail/$paymentHash") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onViewContact = { contactId ->
|
||||||
|
navController.navigate("contacts/$contactId") {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -379,8 +432,8 @@ fun WalletScreen(
|
|||||||
onClose = {
|
onClose = {
|
||||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
},
|
},
|
||||||
onPaymentClick = { payment ->
|
onPaymentClick = { item ->
|
||||||
navController.navigate("payment_detail/${payment.checkingId}")
|
navController.navigate("payment_detail/${item.paymentHash}")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -388,25 +441,21 @@ fun WalletScreen(
|
|||||||
// Payment detail — back → history (natural back stack)
|
// Payment detail — back → history (natural back stack)
|
||||||
composable(
|
composable(
|
||||||
route = ROUTE_PAYMENT_DETAIL,
|
route = ROUTE_PAYMENT_DETAIL,
|
||||||
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
|
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
|
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
|
||||||
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
|
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
|
||||||
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
|
val item = remember(feedItems, paymentHash) {
|
||||||
val payment = remember(historyState, fallbackState, checkingId) {
|
historyVm.findPayment(paymentHash)
|
||||||
(historyState as? HistoryState.Success)
|
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
|
||||||
?: (fallbackState as? HistoryState.Success)
|
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
|
||||||
}
|
}
|
||||||
if (payment != null) {
|
if (item != null) {
|
||||||
PaymentDetailScreen(
|
PaymentDetailScreen(
|
||||||
payment = payment,
|
item = item,
|
||||||
vm = historyVm,
|
vm = historyVm,
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LaunchedEffect(checkingId) { historyVm.refresh() }
|
LaunchedEffect(paymentHash) { historyVm.refresh() }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -415,6 +464,114 @@ fun WalletScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
ROUTE_NWC,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) {
|
||||||
|
NwcScreen(
|
||||||
|
viewModel = nwcVm,
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
},
|
||||||
|
onConnectionClick = { pubkey ->
|
||||||
|
navController.navigate("connection_detail/$pubkey") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = ROUTE_CONNECTION_DETAIL,
|
||||||
|
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) { backStackEntry ->
|
||||||
|
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
|
||||||
|
|
||||||
|
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
|
||||||
|
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
|
||||||
|
|
||||||
|
when (val state = connectionState) {
|
||||||
|
is ConnectionDetailState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionDetailState.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionDetailState.Success -> {
|
||||||
|
ConnectionDetailScreen(
|
||||||
|
connection = state.connection,
|
||||||
|
budgets = state.budgets,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onRevoke = {
|
||||||
|
nwcVm.deleteConnection(pubkey, strings)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
ROUTE_CONTACTS,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) {
|
||||||
|
ContactsScreen(
|
||||||
|
viewModel = contactsVm,
|
||||||
|
onContactClick = { contactId ->
|
||||||
|
navController.navigate("contacts/$contactId") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = ROUTE_CONTACT_DETAIL,
|
||||||
|
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) { backStackEntry ->
|
||||||
|
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
||||||
|
val detailVm: ContactDetailViewModel = viewModel(
|
||||||
|
key = contactId,
|
||||||
|
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
||||||
|
)
|
||||||
|
ContactDetailScreen(
|
||||||
|
viewModel = detailVm,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onPayAddress = { address ->
|
||||||
|
vm.prefillSend(address)
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = false // don't restore stale SendScreen state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clipboard banner — floats over content, slides in from top ────
|
// ── Clipboard banner — floats over content, slides in from top ────
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||||
@@ -19,7 +23,9 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
|
import com.bitcointxoko.gudariwallet.ui.send.SendEvent
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
private const val TAG = "WalletViewModel"
|
private const val TAG = "WalletViewModel"
|
||||||
@@ -28,6 +34,8 @@ class WalletViewModel(
|
|||||||
val repo : WalletRepository,
|
val repo : WalletRepository,
|
||||||
val aliasRepo : NodeAliasRepository,
|
val aliasRepo : NodeAliasRepository,
|
||||||
val paymentCache: PaymentCacheRepository,
|
val paymentCache: PaymentCacheRepository,
|
||||||
|
val nwcCache : NwcCacheRepository,
|
||||||
|
val contactRepo : ContactRepository,
|
||||||
private val balancePrefs: BalancePrefsStore,
|
private val balancePrefs: BalancePrefsStore,
|
||||||
private val lnAddressStore : LightningAddressStore,
|
private val lnAddressStore : LightningAddressStore,
|
||||||
val balanceVm : BalanceViewModel,
|
val balanceVm : BalanceViewModel,
|
||||||
@@ -97,7 +105,8 @@ class WalletViewModel(
|
|||||||
amountMsat: Long,
|
amountMsat: Long,
|
||||||
comment : String?,
|
comment : String?,
|
||||||
strings : SendStrings,
|
strings : SendStrings,
|
||||||
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings)
|
contactId : String?
|
||||||
|
) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings, contactId)
|
||||||
|
|
||||||
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
|
fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) =
|
||||||
sendVm.payLnurlInvoice(state, strings)
|
sendVm.payLnurlInvoice(state, strings)
|
||||||
@@ -109,6 +118,16 @@ class WalletViewModel(
|
|||||||
|
|
||||||
val sendEvents: SharedFlow<SendEvent> = sendVm.events
|
val sendEvents: SharedFlow<SendEvent> = sendVm.events
|
||||||
|
|
||||||
|
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
|
||||||
|
private val _pendingSend = MutableStateFlow<String?>(null)
|
||||||
|
val pendingSend: StateFlow<String?> get() = _pendingSend
|
||||||
|
|
||||||
|
/** Called by ContactDetailScreen before navigating to the Send tab. */
|
||||||
|
fun prefillSend(address: String) { _pendingSend.value = address }
|
||||||
|
|
||||||
|
/** Called by SendScreen once it has consumed the prefilled address. */
|
||||||
|
fun consumePendingSend() { _pendingSend.value = null }
|
||||||
|
|
||||||
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
|
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
|
||||||
receiveVm.handleWithdrawResponse(raw, lnurl)
|
receiveVm.handleWithdrawResponse(raw, lnurl)
|
||||||
}
|
}
|
||||||
@@ -124,6 +143,32 @@ class WalletViewModel(
|
|||||||
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
||||||
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
||||||
|
|
||||||
|
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
|
||||||
|
sendVm.sendState
|
||||||
|
.map { state ->
|
||||||
|
val lnurl = (state as? SendState.LnurlReady)?.lnurl
|
||||||
|
?: return@map null
|
||||||
|
contactRepo.findByLnAddress(lnurl)
|
||||||
|
?: contactRepo.findByLnurl(lnurl)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observePaymentEvents()
|
observePaymentEvents()
|
||||||
fetchLightningAddress()
|
fetchLightningAddress()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
|
|||||||
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||||
@@ -24,17 +26,19 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||||
|
|
||||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||||
|
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
val secrets = EncryptedSecretStore(app)
|
val secrets = EncryptedSecretStore(app)
|
||||||
val api = LNbitsClient.create(secrets)
|
|
||||||
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
|
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
|
||||||
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
|
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
|
||||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
|
val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
|
||||||
val lnurlCache = LnurlCacheRepository(app)
|
val lnurlCache = LnurlCacheRepository(app)
|
||||||
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
||||||
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
|
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
|
||||||
val poller = PaymentPoller(repo)
|
val poller = PaymentPoller(repo)
|
||||||
val paymentCache = PaymentCacheRepository(app)
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
|
val nwcCache = NwcCacheRepository(app)
|
||||||
|
val contactRepo = ContactRepository(app)
|
||||||
val fiatDataStore = FiatDataStore(app)
|
val fiatDataStore = FiatDataStore(app)
|
||||||
val balancePrefs = BalancePrefsStore(app)
|
val balancePrefs = BalancePrefsStore(app)
|
||||||
val lnAddressStore = LightningAddressStore(app)
|
val lnAddressStore = LightningAddressStore(app)
|
||||||
@@ -48,6 +52,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
scanner = lnurlScanner,
|
scanner = lnurlScanner,
|
||||||
payer = lnurlPayer,
|
payer = lnurlPayer,
|
||||||
poller = poller,
|
poller = poller,
|
||||||
|
contactRepo = contactRepo,
|
||||||
|
paymentCacheRepo = paymentCache
|
||||||
)
|
)
|
||||||
|
|
||||||
val clipboardVm = ClipboardViewModel(
|
val clipboardVm = ClipboardViewModel(
|
||||||
@@ -62,6 +68,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
repo,
|
repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
|
nwcCache = nwcCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
balancePrefs = balancePrefs,
|
balancePrefs = balancePrefs,
|
||||||
balanceVm = balanceVm,
|
balanceVm = balanceVm,
|
||||||
fiatVm = fiatVm,
|
fiatVm = fiatVm,
|
||||||
|
|||||||
@@ -75,10 +75,9 @@ class BalanceViewModel(
|
|||||||
runCatching { repo.getBalance() }
|
runCatching { repo.getBalance() }
|
||||||
.onSuccess { balance ->
|
.onSuccess { balance ->
|
||||||
val sats = balance.sats
|
val sats = balance.sats
|
||||||
val prev = (_balanceState.value as? BalanceState.Success)?.sats
|
when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
|
||||||
when {
|
null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
|
||||||
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
|
sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
|
||||||
prev == sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
|
|
||||||
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
|
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
|
||||||
}
|
}
|
||||||
_balanceState.value = BalanceState.Success(
|
_balanceState.value = BalanceState.Success(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AmountWithFiatColumn(
|
||||||
|
amountSat : Long,
|
||||||
|
isOutgoing : Boolean,
|
||||||
|
amountColor : Color,
|
||||||
|
fiatLine : String?,
|
||||||
|
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||||
|
) {
|
||||||
|
val prefix = if (isOutgoing) "−" else "+"
|
||||||
|
Column(horizontalAlignment = horizontalAlignment) {
|
||||||
|
Text(
|
||||||
|
text = "$prefix$amountSat sats",
|
||||||
|
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = amountColor
|
||||||
|
)
|
||||||
|
if (fiatLine != null) {
|
||||||
|
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
||||||
|
Text(
|
||||||
|
text = fiatLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages screen brightness for QR-display screens.
|
||||||
|
* Returns a [BrightnessController] with [isOn] state and a [toggle] callback.
|
||||||
|
* Restores system brightness unconditionally when it leaves composition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberBrightnessController(): BrightnessController {
|
||||||
|
val window = (LocalActivity.current)?.window
|
||||||
|
var isOn by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sync side effect: no coroutine needed
|
||||||
|
SideEffect {
|
||||||
|
window?.attributes = window.attributes?.apply {
|
||||||
|
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||||
|
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
window?.attributes = window.attributes?.apply {
|
||||||
|
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No remember needed — BrightnessController is a tiny data class,
|
||||||
|
// and this composable already recomposes only when isOn changes.
|
||||||
|
return BrightnessController(isOn = isOn, toggle = { isOn = !isOn })
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BrightnessController(
|
||||||
|
val isOn : Boolean,
|
||||||
|
val toggle : () -> Unit
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
// ── Contact avatar ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun String.toHslColor(
|
||||||
|
saturation: Float = 0.5f,
|
||||||
|
lightness: Float = 0.4f
|
||||||
|
): Color {
|
||||||
|
val hue = fold(0) { acc, char -> char.code + acc * 37 } % 360
|
||||||
|
return Color.hsl(abs(hue).toFloat(), saturation, lightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactAvatar(
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 40.dp
|
||||||
|
) {
|
||||||
|
val bgColor = remember(name) {
|
||||||
|
name.toHslColor()
|
||||||
|
}
|
||||||
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawCircle(SolidColor(bgColor))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = initial,
|
||||||
|
color = Color.White,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small icon button that copies [value] to the clipboard under [label].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CopyIconButton(
|
||||||
|
label : String,
|
||||||
|
value : String,
|
||||||
|
size : Dp = 32.dp,
|
||||||
|
iconSize : Dp = 16.dp,
|
||||||
|
tint : Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = if (tint == Color.Unspecified)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else tint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailRow(
|
||||||
|
label : String,
|
||||||
|
value : String,
|
||||||
|
subtitle : String? = null, // ← from components
|
||||||
|
monospace : Boolean = false, // ← from history
|
||||||
|
copyable : Boolean = false, // ← from history
|
||||||
|
onCopy : (() -> Unit)? = null, // ← from history
|
||||||
|
maxLines : Int = Int.MAX_VALUE,
|
||||||
|
valueColor : Color = Color.Unspecified,
|
||||||
|
textDecoration : TextDecoration? = null,
|
||||||
|
onValueClick : (() -> Unit)? = null,
|
||||||
|
trailingContent: (@Composable () -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val baseStyle = MaterialTheme.typography.bodySmall
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = baseStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(0.35f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(0.65f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = baseStyle.copy(
|
||||||
|
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
textDecoration = textDecoration
|
||||||
|
),
|
||||||
|
color = valueColor,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copyable) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trailingContent?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailSection(
|
||||||
|
title : String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.receive.AmountDisplay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared success screen used by both Send (PaymentSent) and Receive (PaymentReceived).
|
||||||
|
*
|
||||||
|
* @param title Headline string, e.g. strings.paymentSent / strings.receive.paymentReceived
|
||||||
|
* @param amountSats Amount in sats.
|
||||||
|
* @param fiatLabel Optional fiat conversion string (from [fiatLabel] util).
|
||||||
|
* @param subLine Optional secondary line below amount: fee text (send) or memo (receive).
|
||||||
|
* @param detailsEnabled Whether the Details button is clickable.
|
||||||
|
* @param onDetails Navigate to payment detail.
|
||||||
|
* @param secondaryLabel Label of the reset TextButton ("Send Another" / "Done").
|
||||||
|
* @param onSecondary Reset screen state.
|
||||||
|
* @param lnurl Pre-fill address for the save-contact sheet. Pass null to hide the button.
|
||||||
|
* @param onSaveContact Forward to vm.saveContactFromSend — omit (null) to hide save-contact UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PaymentSuccessContent(
|
||||||
|
title : String,
|
||||||
|
amountSats : Long,
|
||||||
|
fiatLabel : String?,
|
||||||
|
subLine : String? = null,
|
||||||
|
detailsEnabled : Boolean = true,
|
||||||
|
onDetails : () -> Unit,
|
||||||
|
secondaryLabel : String,
|
||||||
|
onSecondary : () -> Unit,
|
||||||
|
lnurl : String? = null,
|
||||||
|
onSaveContact : ((name: String, addressType: PaymentAddressType?, address: String?) -> Unit)? = null,
|
||||||
|
onViewContact : ((contactId: String) -> Unit)? = null,
|
||||||
|
contactId : String? = null,
|
||||||
|
contactName : String? = null
|
||||||
|
) {
|
||||||
|
var showSaveSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||||
|
if (!subLine.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = subLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
// ── Save contact affordance (send-side only) ─────────────────────────
|
||||||
|
when {
|
||||||
|
contactName != null -> {
|
||||||
|
// Contact already known — show their name
|
||||||
|
val canNavigate = contactId != null && onViewContact != null
|
||||||
|
AssistChip(
|
||||||
|
onClick = { if (canNavigate) onViewContact!!(contactId!!) },
|
||||||
|
label = { Text(contactName) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(AssistChipDefaults.IconSize)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canNavigate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lnurl != null && onSaveContact != null -> {
|
||||||
|
// Unknown sender — offer to save
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showSaveSheet = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Save contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onDetails,
|
||||||
|
enabled = detailsEnabled,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text(LocalAppStrings.current.details) }
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onSecondary,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text(secondaryLabel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bottom sheet ─────────────────────────────────────────────────────────
|
||||||
|
if (showSaveSheet && lnurl != null && onSaveContact != null) {
|
||||||
|
// Detect whether the string is a Lightning Address (user@domain) or LNURL
|
||||||
|
val isLnAddress = lnurl.contains('@') && !lnurl.startsWith("lnurl", ignoreCase = true)
|
||||||
|
val initialType = if (isLnAddress)
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
else
|
||||||
|
PaymentAddressType.LNURL
|
||||||
|
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddress = lnurl,
|
||||||
|
initialAddressType = initialType,
|
||||||
|
onSave = { name, addressType, address ->
|
||||||
|
onSaveContact(name, addressType, address)
|
||||||
|
showSaveSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showSaveSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-9
@@ -1,4 +1,4 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.receive
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Nfc
|
import androidx.compose.material.icons.filled.Nfc
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
@@ -55,6 +56,22 @@ import kotlinx.coroutines.launch
|
|||||||
* @param onToggleNfc Called when the NFC button is tapped.
|
* @param onToggleNfc Called when the NFC button is tapped.
|
||||||
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
|
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* QR image followed by Share / Copy / Brightness / NFC / Open-in-app icon-button row.
|
||||||
|
*
|
||||||
|
* @param content What to encode into the QR code.
|
||||||
|
* @param contentDescription Accessibility label for the QR image.
|
||||||
|
* @param clipLabel Label used when writing to the clipboard.
|
||||||
|
* @param shareTitle Chooser title for the share intent.
|
||||||
|
* @param textToCopy The raw string placed on the clipboard and shared.
|
||||||
|
* @param brightnessOn Current brightness-boost state.
|
||||||
|
* @param onToggleBrightness Called when the brightness button is tapped.
|
||||||
|
* @param isNfcEmulating Current NFC emulation state (null = hide NFC button).
|
||||||
|
* @param onToggleNfc Called when the NFC button is tapped.
|
||||||
|
* @param nfcEnabled Whether the NFC button should be interactive.
|
||||||
|
* @param onOpenUri When non-null, shows an "Open in app" icon button that
|
||||||
|
* fires this callback. Pass null (default) to hide it.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun QrDisplayCard(
|
internal fun QrDisplayCard(
|
||||||
modifier : Modifier = Modifier,
|
modifier : Modifier = Modifier,
|
||||||
@@ -65,9 +82,10 @@ internal fun QrDisplayCard(
|
|||||||
textToCopy : String,
|
textToCopy : String,
|
||||||
brightnessOn : Boolean,
|
brightnessOn : Boolean,
|
||||||
onToggleBrightness : () -> Unit,
|
onToggleBrightness : () -> Unit,
|
||||||
isNfcEmulating : Boolean? = null, // null → no NFC button shown
|
isNfcEmulating : Boolean? = null,
|
||||||
onToggleNfc : (() -> Unit)? = null,
|
onToggleNfc : (() -> Unit)? = null,
|
||||||
nfcEnabled : Boolean = true,
|
nfcEnabled : Boolean = true,
|
||||||
|
onOpenUri : (() -> Unit)? = null, // ← NEW
|
||||||
qrModifier : Modifier = Modifier.size(260.dp)
|
qrModifier : Modifier = Modifier.size(260.dp)
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -91,7 +109,7 @@ internal fun QrDisplayCard(
|
|||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Action row: Share | Copy | Brightness | NFC ───────────────────────
|
// ── Action row: Share | Copy | Brightness | NFC | Open ────────────────
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
@@ -103,7 +121,7 @@ internal fun QrDisplayCard(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Share,
|
imageVector = Icons.Filled.Share,
|
||||||
contentDescription = strings.qrShare, // ← "Share"
|
contentDescription = strings.receive.qrShare,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +138,7 @@ internal fun QrDisplayCard(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.ContentCopy,
|
imageVector = Icons.Filled.ContentCopy,
|
||||||
contentDescription = strings.qrCopy, // ← "Copy"
|
contentDescription = strings.receive.qrCopy,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,8 +147,8 @@ internal fun QrDisplayCard(
|
|||||||
FilledTonalIconButton(onClick = onToggleBrightness) {
|
FilledTonalIconButton(onClick = onToggleBrightness) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.WbSunny,
|
imageVector = Icons.Filled.WbSunny,
|
||||||
contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness"
|
contentDescription = if (brightnessOn) strings.receive.brightnessReduce
|
||||||
else strings.brightnessBoost, // ← "Boost brightness"
|
else strings.receive.brightnessBoost,
|
||||||
tint = if (brightnessOn) MaterialTheme.colorScheme.primary
|
tint = if (brightnessOn) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -145,17 +163,29 @@ internal fun QrDisplayCard(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isNfcEmulating) Icons.Filled.Nfc
|
imageVector = if (isNfcEmulating) Icons.Filled.Nfc
|
||||||
else Icons.Outlined.Nfc,
|
else Icons.Outlined.Nfc,
|
||||||
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing"
|
contentDescription = if (isNfcEmulating) strings.receive.qrNfcStop
|
||||||
else strings.qrNfcStart, // ← "Share via NFC"
|
else strings.receive.qrNfcStart,
|
||||||
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
|
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open in app — only shown when caller passes onOpenUri
|
||||||
|
if (onOpenUri != null) {
|
||||||
|
IconButton(onClick = onOpenUri) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
contentDescription = strings.receive.openInApp,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── QR generation ─────────────────────────────────────────────────────────────
|
// ── QR generation ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val QR_BITMAP_SIZE = 512
|
private const val QR_BITMAP_SIZE = 512
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun StatusChip(status: String) {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
data class ChipStyle(
|
||||||
|
val containerColor: Color,
|
||||||
|
val contentColor: Color,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val style = when (status.lowercase()) {
|
||||||
|
"success", "complete", "paid" -> ChipStyle(
|
||||||
|
semantic.successContainer, semantic.onSuccessContainer, strings.history.statusSuccess
|
||||||
|
)
|
||||||
|
"pending", "in_flight", "inflight" -> ChipStyle(
|
||||||
|
semantic.warningContainer, semantic.onWarningContainer, strings.history.statusPending
|
||||||
|
)
|
||||||
|
"failed", "error", "expired" -> ChipStyle(
|
||||||
|
semantic.errorContainer, semantic.onErrorContainer, strings.history.statusFailed
|
||||||
|
)
|
||||||
|
else -> ChipStyle(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
strings.history.statusUnknown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = style.containerColor,
|
||||||
|
contentColor = style.contentColor,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = style.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.components
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.ContentCopy
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A two-column label/value row used throughout the send flow.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun DetailRow(
|
|
||||||
label : String,
|
|
||||||
value : String,
|
|
||||||
subtitle : String? = null,
|
|
||||||
valueColor : Color = Color.Unspecified,
|
|
||||||
textDecoration : TextDecoration? = null,
|
|
||||||
onValueClick : (() -> Unit)? = null,
|
|
||||||
trailingContent: (@Composable () -> Unit)? = null
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(0.35f)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(0.65f)
|
|
||||||
.padding(start = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = valueColor,
|
|
||||||
style = LocalTextStyle.current.copy(textDecoration = textDecoration),
|
|
||||||
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
|
|
||||||
else Modifier
|
|
||||||
)
|
|
||||||
if (subtitle != null) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trailingContent?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small icon button that copies [value] to the clipboard under [label].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CopyIconButton(
|
|
||||||
label : String,
|
|
||||||
value : String,
|
|
||||||
size : Dp = 32.dp,
|
|
||||||
iconSize : Dp = 16.dp,
|
|
||||||
tint : Color = Color.Unspecified
|
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboard.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(size)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.ContentCopy,
|
|
||||||
contentDescription = "Copy $label",
|
|
||||||
modifier = Modifier.size(iconSize),
|
|
||||||
tint = if (tint == Color.Unspecified)
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
else tint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Star
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactDetailScreen(
|
||||||
|
viewModel: ContactDetailViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPayAddress: (address: String) -> Unit = {}
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
|
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
||||||
|
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
|
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
var editingAddress by remember { mutableStateOf<PaymentAddressEntity?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
val title = when (val s = uiState) {
|
||||||
|
is ContactDetailUiState.Success ->
|
||||||
|
s.data.contact.localAlias
|
||||||
|
?: s.data.contact.displayName
|
||||||
|
?: s.data.contact.name
|
||||||
|
?: "Contact"
|
||||||
|
else -> "Contact"
|
||||||
|
}
|
||||||
|
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState is ContactDetailUiState.Success) {
|
||||||
|
if (isEditing) {
|
||||||
|
// Done button exits edit mode
|
||||||
|
TextButton(onClick = { isEditing = false }) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edit button opens edit mode
|
||||||
|
IconButton(onClick = { isEditing = true }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// FAB only visible in edit mode so users don't accidentally add
|
||||||
|
// addresses while browsing
|
||||||
|
if (uiState is ContactDetailUiState.Success && isEditing) {
|
||||||
|
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactDetailUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.NotFound -> {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Contact not found",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.Success -> {
|
||||||
|
ContactDetailContent(
|
||||||
|
state = state,
|
||||||
|
isEditing = isEditing,
|
||||||
|
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
|
||||||
|
onSetDefault = { viewModel.setDefaultAddress(it) },
|
||||||
|
onPayAddress = onPayAddress,
|
||||||
|
onEditAlias = { viewModel.openEditAliasSheet() },
|
||||||
|
onEditAddress = { addr -> editingAddress = addr },
|
||||||
|
onDeleteContact = { viewModel.requestDelete() },
|
||||||
|
modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
|
||||||
|
if (pendingDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
|
title = { Text("Delete contact") },
|
||||||
|
text = { Text("This contact and all their addresses will be permanently removed.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.cancelDelete() }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAddressDelete?.let { addressId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingAddressDelete = null },
|
||||||
|
title = { Text("Remove address") },
|
||||||
|
text = { Text("This payment address will be permanently removed from the contact.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.removeAddress(addressId)
|
||||||
|
pendingAddressDelete = null
|
||||||
|
}) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingAddressDelete = null }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet ─────────────────────────────────────────────────
|
||||||
|
if (showAddAddressSheet) {
|
||||||
|
AddAddressSheet(
|
||||||
|
onSave = { type, address, label, makeDefault ->
|
||||||
|
viewModel.addAddress(type, address, label, makeDefault)
|
||||||
|
viewModel.closeAddAddressSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeAddAddressSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit address sheet ──────────────────────────────────────────
|
||||||
|
editingAddress?.let { addr ->
|
||||||
|
EditAddressSheet(
|
||||||
|
address = addr,
|
||||||
|
onSave = { id, type, addressValue, label, makeDefault ->
|
||||||
|
viewModel.updateAddress(id, type, addressValue, label, makeDefault)
|
||||||
|
editingAddress = null
|
||||||
|
},
|
||||||
|
onDismiss = { editingAddress = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias sheet ──────────────────────────────────────────────────
|
||||||
|
if (showEditAliasSheet) {
|
||||||
|
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
||||||
|
?.data?.contact?.localAlias ?: ""
|
||||||
|
EditAliasSheet(
|
||||||
|
currentAlias = currentAlias,
|
||||||
|
onSave = { newAlias ->
|
||||||
|
viewModel.updateAlias(newAlias)
|
||||||
|
viewModel.closeEditAliasSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeEditAliasSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main content ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactDetailContent(
|
||||||
|
state: ContactDetailUiState.Success,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onRemoveAddress: (addressId: String) -> Unit,
|
||||||
|
onSetDefault: (PaymentAddressEntity) -> Unit,
|
||||||
|
onPayAddress: (address: String) -> Unit,
|
||||||
|
onEditAlias: () -> Unit,
|
||||||
|
onEditAddress: (PaymentAddressEntity) -> Unit,
|
||||||
|
onDeleteContact: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val contact = state.data.contact
|
||||||
|
val addresses = state.data.addresses
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
|
||||||
|
) {
|
||||||
|
// ── Avatar + name hero ─────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "?"
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
contact.nip05?.let { nip05 ->
|
||||||
|
Text(
|
||||||
|
text = nip05,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses section ─────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Payment addresses") {
|
||||||
|
if (addresses.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No addresses added yet",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
addresses.forEachIndexed { index, addr ->
|
||||||
|
AddressRow(
|
||||||
|
address = addr,
|
||||||
|
isEditing = isEditing,
|
||||||
|
onPay = { onPayAddress(addr.address) },
|
||||||
|
onRemove = { onRemoveAddress(addr.id) },
|
||||||
|
onSetDefault = { onSetDefault(addr) },
|
||||||
|
onEdit = { onEditAddress(addr) }
|
||||||
|
)
|
||||||
|
if (index < addresses.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
|
||||||
|
contact.about?.let { about ->
|
||||||
|
item {
|
||||||
|
DetailSection(title = "About") {
|
||||||
|
DetailRow(label = "Bio", value = about, maxLines = 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destructive actions (edit mode only) ─────────────────────────
|
||||||
|
if (isEditing) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onEditAlias,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Edit name")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDeleteContact,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Delete contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Address row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddressRow(
|
||||||
|
address: PaymentAddressEntity,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onPay: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onSetDefault: () -> Unit,
|
||||||
|
onEdit: () -> Unit
|
||||||
|
) {
|
||||||
|
val typeLabel = when (address.type) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL.name -> "LNURL"
|
||||||
|
PaymentAddressType.ON_CHAIN.name -> "On-chain"
|
||||||
|
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
|
||||||
|
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
|
||||||
|
PaymentAddressType.BIP353.name -> "BIP-353"
|
||||||
|
else -> address.type
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
enabled = !isEditing,
|
||||||
|
onClickLabel = "Pay with this address"
|
||||||
|
) { onPay() }
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// ── Label + value ─────────────────────────────────────────────
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = address.address,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = typeLabel,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
address.label?.let { lbl ->
|
||||||
|
Text(
|
||||||
|
text = "· $lbl",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (address.isDefault) {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"default",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.height(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trailing actions ──────────────────────────────────────────
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
// Copy always available
|
||||||
|
CopyIconButton(
|
||||||
|
label = "Copy address",
|
||||||
|
value = address.address,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
// Star + delete only in edit mode
|
||||||
|
if (isEditing) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onEdit,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit address",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Remove address",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Add address sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AddAddressSheet(
|
||||||
|
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf("") }
|
||||||
|
var label by remember { mutableStateOf("") }
|
||||||
|
var makeDefault by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val canSave = address.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { addressFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Type chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"Lightning Address" else "LNURL"
|
||||||
|
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"user@domain.com" else "LNURL1..."
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(addressFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional label
|
||||||
|
OutlinedTextField(
|
||||||
|
value = label,
|
||||||
|
onValueChange = { label = it },
|
||||||
|
label = { Text("Label (optional)") },
|
||||||
|
placeholder = { Text("e.g. personal, savings") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set as default toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set as default",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = makeDefault,
|
||||||
|
onCheckedChange = { makeDefault = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit address sheet ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EditAddressSheet(
|
||||||
|
address: PaymentAddressEntity,
|
||||||
|
onSave: (id: String, type: PaymentAddressType, addressValue: String, label: String?, makeDefault: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var selectedType by remember {
|
||||||
|
mutableStateOf(PaymentAddressType.entries.find { it.name == address.type }
|
||||||
|
?: PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
}
|
||||||
|
var addressValue by remember { mutableStateOf(address.address) }
|
||||||
|
var label by remember { mutableStateOf(address.label ?: "") }
|
||||||
|
var makeDefault by remember { mutableStateOf(address.isDefault) }
|
||||||
|
|
||||||
|
val canSave = addressValue.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { addressFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Edit payment address", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Type chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS },
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LNURL },
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"Lightning Address" else "LNURL"
|
||||||
|
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"user@domain.com" else "LNURL1..."
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = addressValue,
|
||||||
|
onValueChange = { addressValue = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(addressFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional label
|
||||||
|
OutlinedTextField(
|
||||||
|
value = label,
|
||||||
|
onValueChange = { label = it },
|
||||||
|
label = { Text("Label (optional)") },
|
||||||
|
placeholder = { Text("e.g. personal, savings") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set as default toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set as default",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = makeDefault,
|
||||||
|
onCheckedChange = { makeDefault = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EditAliasSheet(
|
||||||
|
currentAlias: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var alias by remember { mutableStateOf(currentAlias) }
|
||||||
|
val canSave = alias.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Edit name", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = alias,
|
||||||
|
onValueChange = { alias = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(alias)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = { focusManager.clearFocus(); onSave(alias) },
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactDetailUiState {
|
||||||
|
data object Loading : ContactDetailUiState()
|
||||||
|
data object NotFound : ContactDetailUiState()
|
||||||
|
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactDetailViewModel(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
|
||||||
|
.map { contact ->
|
||||||
|
if (contact == null) ContactDetailUiState.NotFound
|
||||||
|
else ContactDetailUiState.Success(contact)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactDetailUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Delete contact ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _pendingDelete = MutableStateFlow(false)
|
||||||
|
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
|
||||||
|
|
||||||
|
fun requestDelete() { _pendingDelete.value = true }
|
||||||
|
fun cancelDelete() { _pendingDelete.value = false }
|
||||||
|
|
||||||
|
fun confirmDelete(onDeleted: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(contactId)
|
||||||
|
_pendingDelete.value = false
|
||||||
|
onDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun updateAlias(newAlias: String) {
|
||||||
|
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type,
|
||||||
|
address = address.trim(),
|
||||||
|
label = label?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
makeDefault = makeDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateAddress(
|
||||||
|
addressId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String?,
|
||||||
|
makeDefault: Boolean
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.updateAddress(addressId, type, address, label, makeDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun removeAddress(addressId: String) {
|
||||||
|
viewModelScope.launch { repo.removeAddress(addressId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAddress(address: PaymentAddressEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = PaymentAddressType.valueOf(address.type),
|
||||||
|
address = address.address,
|
||||||
|
label = address.label,
|
||||||
|
makeDefault = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet state ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showAddAddressSheet = MutableStateFlow(false)
|
||||||
|
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
|
||||||
|
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
|
||||||
|
|
||||||
|
// ── Edit alias sheet state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showEditAliasSheet = MutableStateFlow(false)
|
||||||
|
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
|
||||||
|
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactDetailViewModel(contactId, repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactPickerSheet(
|
||||||
|
contacts: List<ContactWithAddresses>,
|
||||||
|
onContactSelected: (contactId: String) -> Unit,
|
||||||
|
onCreateNew: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var search by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filtered = remember(contacts, search) {
|
||||||
|
if (search.isBlank()) contacts
|
||||||
|
else contacts.filter { c ->
|
||||||
|
val name = c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: ""
|
||||||
|
name.contains(search, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = search,
|
||||||
|
onValueChange = { search = it },
|
||||||
|
label = { Text("Search") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contact list
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 320.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
items(filtered, key = { it.contact.id }) { item ->
|
||||||
|
val displayName = item.contact.localAlias
|
||||||
|
?: item.contact.displayName
|
||||||
|
?: item.contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = item.addresses.firstOrNull()?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onContactSelected(item.contact.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.isEmpty() && search.isNotBlank()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "No contacts match \"$search\"",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new contact button
|
||||||
|
HorizontalDivider()
|
||||||
|
TextButton(
|
||||||
|
onClick = onCreateNew,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Create new contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen(
|
||||||
|
viewModel: ContactsViewModel,
|
||||||
|
onContactClick: (contactId: String) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var showCreateSheet by remember { mutableStateOf(false) }
|
||||||
|
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Contacts") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
searchVisible = !searchVisible
|
||||||
|
if (!searchVisible) searchQuery = ""
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search contacts"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showCreateSheet = true }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(searchVisible) {
|
||||||
|
if (searchVisible) {
|
||||||
|
// kotlinx.coroutines.delay(50)
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
if (searchVisible) {
|
||||||
|
ContactsSearchBar(
|
||||||
|
query = searchQuery,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
clipboard = clipboard,
|
||||||
|
scope = scope,
|
||||||
|
onQueryChange = { searchQuery = it },
|
||||||
|
onClear = { searchQuery = "" }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactsUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactsUiState.Success -> {
|
||||||
|
val filteredContacts = remember(state.contacts, searchQuery) {
|
||||||
|
if (searchQuery.isBlank()) state.contacts
|
||||||
|
else state.contacts.filter { item ->
|
||||||
|
val contact = item.contact
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: ""
|
||||||
|
val addressMatch = item.addresses.any { addr ->
|
||||||
|
addr.address.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
name.contains(searchQuery, ignoreCase = true) || addressMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredContacts.isEmpty()) {
|
||||||
|
ContactsEmptyContent(
|
||||||
|
onAdd = { showCreateSheet = true },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(filteredContacts, key = { it.contact.id }) { item ->
|
||||||
|
ContactRow(item, onClick = { onContactClick(item.contact.id) })
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateSheet) {
|
||||||
|
CreateContactSheet(
|
||||||
|
onSave = { name, addressType, address ->
|
||||||
|
viewModel.createContact(name, addressType, address)
|
||||||
|
showCreateSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsEmptyContent(
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tap + to add your first contact",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = onAdd) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search bar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsSearchBar(
|
||||||
|
query: String,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
clipboard: androidx.compose.ui.platform.Clipboard,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onClear: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Search contacts") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val pasted = clipboard.getClipEntry()
|
||||||
|
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
|
||||||
|
onQueryChange(pasted)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = "Paste"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onClear,
|
||||||
|
enabled = query.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact row ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(
|
||||||
|
item: ContactWithAddresses,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val contact = item.contact
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
val primaryAddress = item.addresses
|
||||||
|
.firstOrNull { it.isDefault }
|
||||||
|
?: item.addresses.firstOrNull()
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = primaryAddress?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactsUiState {
|
||||||
|
data object Loading : ContactsUiState()
|
||||||
|
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactsViewModel(
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
|
||||||
|
.map { ContactsUiState.Success(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactsUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createContact(
|
||||||
|
name: String,
|
||||||
|
addressType: PaymentAddressType?,
|
||||||
|
address: String?
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
repo.createContact(
|
||||||
|
localAlias = name.trim(),
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContact(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactsViewModel(repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreateContactSheet(
|
||||||
|
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
initialAddress: String? = null
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf(initialAddress ?: "") }
|
||||||
|
|
||||||
|
val nameFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val canSave = name.isNotBlank()
|
||||||
|
|
||||||
|
val addressLabel = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL"
|
||||||
|
else -> "Address"
|
||||||
|
}
|
||||||
|
val addressPlaceholder = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL1..."
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { nameFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "New contact",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(nameFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Address type toggle
|
||||||
|
Text(
|
||||||
|
text = "Payment address (optional)",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LNURL
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) {
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -96,6 +96,17 @@ class FiatViewModel(
|
|||||||
initialValue = null
|
initialValue = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Combined fiat stream for [PaymentFeedRepository] feed assembly. */
|
||||||
|
val fiatFlow: StateFlow<Pair<String?, Double?>> = combine(
|
||||||
|
selectedCurrency,
|
||||||
|
fiatSatsPerUnit
|
||||||
|
) { currency, rate -> Pair(currency, rate) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = Pair(null, null)
|
||||||
|
)
|
||||||
|
|
||||||
// ── Called by BalanceViewModel after every successful balance refresh ─────
|
// ── Called by BalanceViewModel after every successful balance refresh ─────
|
||||||
fun onBalanceRefreshed() {
|
fun onBalanceRefreshed() {
|
||||||
viewModelScope.launch { fetchAndApplyFiatRate() }
|
viewModelScope.launch { fetchAndApplyFiatRate() }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -61,14 +62,14 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
// ── Sheet title ───────────────────────────────────────────────────
|
// ── Sheet title ───────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSheetTitle, // ← "Filter payments"
|
text = strings.history.filterSheetTitle, // ← "Filter payments"
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// ── Direction ─────────────────────────────────────────────────────
|
// ── Direction ─────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionDirection, // ← "Direction"
|
text = strings.history.filterSectionDirection, // ← "Direction"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -76,9 +77,9 @@ internal fun FilterBottomSheet(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DirectionFilter.entries.forEach { option ->
|
DirectionFilter.entries.forEach { option ->
|
||||||
val label = when (option) {
|
val label = when (option) {
|
||||||
DirectionFilter.ALL -> strings.filterDirectionAll // ← "All"
|
DirectionFilter.ALL -> strings.history.filterDirectionAll // ← "All"
|
||||||
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
|
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
|
||||||
DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming"
|
DirectionFilter.INCOMING -> strings.history.filterDirectionIncoming // ← "Incoming"
|
||||||
}
|
}
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentFilter.direction == option,
|
selected = currentFilter.direction == option,
|
||||||
@@ -91,7 +92,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Status ────────────────────────────────────────────────────────
|
// ── Status ────────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionStatus, // ← "Status"
|
text = strings.history.filterSectionStatus, // ← "Status"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -104,9 +105,9 @@ internal fun FilterBottomSheet(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (option) {
|
when (option) {
|
||||||
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
|
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
|
||||||
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
|
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
|
||||||
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
|
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ internal fun FilterBottomSheet(
|
|||||||
if (availableTypes.isNotEmpty()) {
|
if (availableTypes.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionType, // ← "Type"
|
text = strings.history.filterSectionType, // ← "Type"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -137,7 +138,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Amount (sats) ─────────────────────────────────────────────────
|
// ── Amount (sats) ─────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionAmount, // ← "Amount (sats)"
|
text = strings.history.filterSectionAmount, // ← "Amount (sats)"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -158,8 +159,8 @@ internal fun FilterBottomSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = minText,
|
value = minText,
|
||||||
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
||||||
label = { Text(strings.filterAmountMin_label) }, // ← "Min"
|
label = { Text(strings.history.filterAmountMin_label) }, // ← "Min"
|
||||||
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
|
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -168,8 +169,8 @@ internal fun FilterBottomSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = maxText,
|
value = maxText,
|
||||||
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
||||||
label = { Text(strings.filterAmountMax_label) }, // ← "Max"
|
label = { Text(strings.history.filterAmountMax_label) }, // ← "Max"
|
||||||
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
|
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -179,7 +180,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Date ──────────────────────────────────────────────────────────
|
// ── Date ──────────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionDate, // ← "Date"
|
text = strings.history.filterSectionDate, // ← "Date"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -189,9 +190,9 @@ internal fun FilterBottomSheet(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DatePreset.entries.forEach { preset ->
|
DatePreset.entries.forEach { preset ->
|
||||||
val label = when (preset) {
|
val label = when (preset) {
|
||||||
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
|
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
|
||||||
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
|
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
|
||||||
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
|
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
|
||||||
}
|
}
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentFilter.datePreset == preset,
|
selected = currentFilter.datePreset == preset,
|
||||||
@@ -219,8 +220,8 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
currentFilter.minCreatedAt
|
currentFilter.minCreatedAt
|
||||||
?.let { formatEpochShort(it) }
|
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
|
||||||
?: strings.filterDateFrom_label // ← "From"
|
?: strings.history.filterDateFrom_label // ← "From"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text("–", style = MaterialTheme.typography.bodyLarge)
|
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||||
@@ -230,8 +231,8 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
currentFilter.maxCreatedAt
|
currentFilter.maxCreatedAt
|
||||||
?.let { formatEpochShort(it) }
|
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
|
||||||
?: strings.filterDateTo_label // ← "To"
|
?: strings.history.filterDateTo_label // ← "To"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,11 +248,11 @@ internal fun FilterBottomSheet(
|
|||||||
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
||||||
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
||||||
showFromPicker = false
|
showFromPicker = false
|
||||||
}) { Text(strings.filterDialogOk) } // ← "OK"
|
}) { Text(strings.history.filterDialogOk) } // ← "OK"
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showFromPicker = false }) {
|
TextButton(onClick = { showFromPicker = false }) {
|
||||||
Text(strings.filterDialogCancel) // ← "Cancel"
|
Text(strings.history.filterDialogCancel) // ← "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { DatePicker(state = pickerState) }
|
) { DatePicker(state = pickerState) }
|
||||||
@@ -269,11 +270,11 @@ internal fun FilterBottomSheet(
|
|||||||
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
||||||
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
||||||
showToPicker = false
|
showToPicker = false
|
||||||
}) { Text(strings.filterDialogOk) } // ← "OK"
|
}) { Text(strings.history.filterDialogOk) } // ← "OK"
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showToPicker = false }) {
|
TextButton(onClick = { showToPicker = false }) {
|
||||||
Text(strings.filterDialogCancel) // ← "Cancel"
|
Text(strings.history.filterDialogCancel) // ← "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { DatePicker(state = pickerState) }
|
) { DatePicker(state = pickerState) }
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.history
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
|
||||||
|
|
||||||
// ── Amount + fiat column ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun AmountWithFiatColumn(
|
|
||||||
amountSat : Long,
|
|
||||||
isOutgoing : Boolean,
|
|
||||||
amountColor : Color,
|
|
||||||
fiatLine : String?,
|
|
||||||
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
|
||||||
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
|
||||||
) {
|
|
||||||
val prefix = if (isOutgoing) "−" else "+"
|
|
||||||
Column(horizontalAlignment = horizontalAlignment) {
|
|
||||||
Text(
|
|
||||||
text = "$prefix$amountSat sats",
|
|
||||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
color = amountColor
|
|
||||||
)
|
|
||||||
if (fiatLine != null) {
|
|
||||||
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
|
||||||
Text(
|
|
||||||
text = fiatLine,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status chip ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun StatusChip(status: String) {
|
|
||||||
val semantic = semanticColors()
|
|
||||||
val strings = LocalAppStrings.current // or however you access Lyricist strings
|
|
||||||
|
|
||||||
data class ChipStyle(
|
|
||||||
val containerColor: Color,
|
|
||||||
val contentColor: Color,
|
|
||||||
val label: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
val style = when (status.lowercase()) {
|
|
||||||
"success", "complete", "paid" -> ChipStyle(
|
|
||||||
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
|
|
||||||
)
|
|
||||||
"pending", "in_flight", "inflight" -> ChipStyle(
|
|
||||||
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
|
|
||||||
)
|
|
||||||
"failed", "error", "expired" -> ChipStyle(
|
|
||||||
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
|
|
||||||
)
|
|
||||||
else -> ChipStyle(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
strings.statusUnknown,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
color = style.containerColor,
|
|
||||||
contentColor = style.contentColor,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = style.label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Section card wrapper ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DetailSection(
|
|
||||||
title : String,
|
|
||||||
content: @Composable ColumnScope.() -> Unit
|
|
||||||
) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Label / value row ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DetailRow(
|
|
||||||
label : String,
|
|
||||||
value : String,
|
|
||||||
monospace : Boolean = false,
|
|
||||||
copyable : Boolean = false,
|
|
||||||
onCopy : (() -> Unit)? = null,
|
|
||||||
maxLines : Int = Int.MAX_VALUE,
|
|
||||||
valueColor : Color = Color.Unspecified,
|
|
||||||
textDecoration : TextDecoration? = null,
|
|
||||||
onValueClick : (() -> Unit)? = null,
|
|
||||||
trailingContent: (@Composable () -> Unit)? = null
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(0.35f)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(0.65f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = if (monospace)
|
|
||||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
|
|
||||||
else
|
|
||||||
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
|
|
||||||
color = valueColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f, fill = false)
|
|
||||||
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
|
|
||||||
maxLines = maxLines,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (copyable) {
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ContentCopy,
|
|
||||||
contentDescription = "Copy $label",
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trailingContent?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,27 +56,30 @@ import androidx.compose.ui.focus.focusRequester
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── List screen ──────────────────────────────────────────────────────────────
|
// ── List screen ───────────────────────────────────────────────────────────────
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(
|
fun HistoryScreen(
|
||||||
vm : HistoryViewModel,
|
vm : HistoryViewModel,
|
||||||
onClose : () -> Unit,
|
onClose : () -> Unit,
|
||||||
onPaymentClick : (PaymentRecord) -> Unit
|
onPaymentClick : (PaymentFeedItem) -> Unit // ← PaymentRecord → PaymentFeedItem
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val state by vm.filteredState.collectAsState()
|
val state by vm.state.collectAsState() // ← sync status only (Loading/Error/Success shell)
|
||||||
|
val items by vm.feedItems.collectAsState() // ← filtered List<PaymentFeedItem>
|
||||||
val filter by vm.filter.collectAsState()
|
val filter by vm.filter.collectAsState()
|
||||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
|
||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
|
||||||
val availableTypes by vm.availableTypes.collectAsState()
|
val availableTypes by vm.availableTypes.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// fiatCurrency, fiatSatsPerUnit, contactNames — all removed:
|
||||||
|
// they are now embedded in each PaymentFeedItem
|
||||||
|
|
||||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
val dismissFilterSheet = { filterSheetVisible = false }
|
val dismissFilterSheet = { filterSheetVisible = false }
|
||||||
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -88,12 +91,12 @@ fun HistoryScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(strings.historyTitle) }, // ← "History"
|
title = { Text(strings.history.historyTitle) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historyClose // ← "Close"
|
contentDescription = strings.history.historyClose
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -107,7 +110,7 @@ fun HistoryScreen(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
contentDescription = strings.historySearchPayments // ← "Search payments"
|
contentDescription = strings.history.historySearchPayments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,7 @@ fun HistoryScreen(
|
|||||||
IconButton(onClick = { filterSheetVisible = true }) {
|
IconButton(onClick = { filterSheetVisible = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FilterList,
|
imageVector = Icons.Default.FilterList,
|
||||||
contentDescription = strings.historyFilterPayments // ← "Filter payments"
|
contentDescription = strings.history.historyFilterPayments
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,7 @@ fun HistoryScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
// ── Search bar ───────────────────────────────────────────────────
|
// ── Search bar ─────────────────────────────────────────────────
|
||||||
if (searchVisible) {
|
if (searchVisible) {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = filter.searchQuery,
|
query = filter.searchQuery,
|
||||||
@@ -152,7 +155,7 @@ fun HistoryScreen(
|
|||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active filter chips ──────────────────────────────────────────
|
// ── Active filter chips ────────────────────────────────────────
|
||||||
val searchActive = filter.searchQuery.isNotBlank()
|
val searchActive = filter.searchQuery.isNotBlank()
|
||||||
if (isFiltered || searchActive) {
|
if (isFiltered || searchActive) {
|
||||||
ActiveFilterChips(
|
ActiveFilterChips(
|
||||||
@@ -170,32 +173,12 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content ──────────────────────────────────────────────────────
|
// ── Content ────────────────────────────────────────────────────
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val s = state) {
|
|
||||||
|
|
||||||
is HistoryState.Loading -> {
|
// ── List — always rendered when state is Success, never unmounted ──────
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
val s = state
|
||||||
}
|
if (s is HistoryState.Success) {
|
||||||
|
|
||||||
is HistoryState.Error -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.align(Alignment.Center),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = s.message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Button(onClick = { vm.refresh() }) {
|
|
||||||
Text(strings.historyRetry) // ← "Retry"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is HistoryState.Success -> {
|
|
||||||
val shouldLoadMore by remember {
|
val shouldLoadMore by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
@@ -207,9 +190,9 @@ fun HistoryScreen(
|
|||||||
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.payments.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.historyNoPayments, // ← "No payments yet."
|
text = strings.history.historyNoPayments,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -225,15 +208,13 @@ fun HistoryScreen(
|
|||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = s.payments,
|
items = items,
|
||||||
key = { it.paymentHash },
|
key = { it.paymentHash },
|
||||||
contentType = { "payment" }
|
contentType = { "payment" }
|
||||||
) { payment ->
|
) { item ->
|
||||||
PaymentRow(
|
PaymentRow(
|
||||||
payment = payment,
|
item = item,
|
||||||
fiatCurrency = fiatCurrency,
|
onClick = { onPaymentClick(item) }
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
|
||||||
onClick = { onPaymentClick(payment) }
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
@@ -259,11 +240,41 @@ fun HistoryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── State overlays — sit on top of the list, never destroy it ─────────
|
||||||
|
when (val s = state) {
|
||||||
|
is HistoryState.Loading -> {
|
||||||
|
// Only show the full-screen spinner on the very first load
|
||||||
|
// (when we have no items yet). During loadMore(), the in-list
|
||||||
|
// footer spinner handles it — don't obscure the results.
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is HistoryState.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = s.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = { vm.refresh() }) {
|
||||||
|
Text(strings.history.historyRetry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is HistoryState.Success -> { /* handled above */ }
|
||||||
}
|
}
|
||||||
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sheets (outside Scaffold so they overlay correctly) ───────────────────
|
||||||
if (filterSheetVisible) {
|
if (filterSheetVisible) {
|
||||||
FilterBottomSheet(
|
FilterBottomSheet(
|
||||||
currentFilter = filter,
|
currentFilter = filter,
|
||||||
@@ -288,7 +299,7 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Search bar ───────────────────────────────────────────────────────────────
|
// ── Search bar ────────────────────────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchBar(
|
private fun SearchBar(
|
||||||
query : String,
|
query : String,
|
||||||
@@ -309,13 +320,13 @@ private fun SearchBar(
|
|||||||
IconButton(onClick = onInfoClick) {
|
IconButton(onClick = onInfoClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Info,
|
imageVector = Icons.Default.Info,
|
||||||
contentDescription = strings.historySearchHelp // ← "Search help"
|
contentDescription = strings.history.historySearchHelp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions"
|
placeholder = { Text(strings.history.historySearchPlaceholder) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
@@ -336,7 +347,7 @@ private fun SearchBar(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ContentPaste,
|
imageVector = Icons.Default.ContentPaste,
|
||||||
contentDescription = strings.historySearchPaste // ← "Paste"
|
contentDescription = strings.history.historySearchPaste
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -345,7 +356,7 @@ private fun SearchBar(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historySearchClear, // ← "Clear search"
|
contentDescription = strings.history.historySearchClear,
|
||||||
tint = if (query.isNotEmpty()) LocalContentColor.current
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
)
|
)
|
||||||
@@ -353,13 +364,13 @@ private fun SearchBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Active filter chips ──────────────────────────────────────────────────────
|
// ── Active filter chips ───────────────────────────────────────────────────────
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveFilterChips(
|
private fun ActiveFilterChips(
|
||||||
filter : PaymentFilter,
|
filter : PaymentFilter,
|
||||||
searchActive : Boolean,
|
searchActive : Boolean,
|
||||||
onClearDirection: () -> Unit,
|
onClearDirection : () -> Unit,
|
||||||
onClearStatus : (StatusFilter) -> Unit,
|
onClearStatus : (StatusFilter) -> Unit,
|
||||||
onClearType : (String) -> Unit,
|
onClearType : (String) -> Unit,
|
||||||
onClearAmount : () -> Unit,
|
onClearAmount : () -> Unit,
|
||||||
@@ -381,15 +392,15 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (filter.direction) {
|
when (filter.direction) {
|
||||||
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
|
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing
|
||||||
else -> strings.filterDirectionIncoming // ← "Incoming"
|
else -> strings.history.filterDirectionIncoming
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearDirection, // ← "Clear direction filter"
|
contentDescription = strings.history.filterClearDirection,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -402,16 +413,16 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (s) {
|
when (s) {
|
||||||
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
|
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted
|
||||||
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
|
StatusFilter.PENDING -> strings.history.filterStatusPending
|
||||||
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
|
StatusFilter.FAILED -> strings.history.filterStatusFailed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter"
|
contentDescription = strings.history.filterRemoveStatus,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -425,7 +436,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterRemoveType, // ← "Remove type filter"
|
contentDescription = strings.history.filterRemoveType,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -434,11 +445,11 @@ private fun ActiveFilterChips(
|
|||||||
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||||
val label = when {
|
val label = when {
|
||||||
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||||
strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats"
|
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat)
|
||||||
filter.minAmountSat != null ->
|
filter.minAmountSat != null ->
|
||||||
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
|
strings.history.filterAmountMin(filter.minAmountSat)
|
||||||
else ->
|
else ->
|
||||||
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
|
strings.history.filterAmountMax(filter.maxAmountSat!!)
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
@@ -447,7 +458,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearAmount, // ← "Clear amount filter"
|
contentDescription = strings.history.filterClearAmount,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -457,19 +468,23 @@ private fun ActiveFilterChips(
|
|||||||
val maxCreatedAt = filter.maxCreatedAt
|
val maxCreatedAt = filter.maxCreatedAt
|
||||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||||
val label = when (filter.datePreset) {
|
val label = when (filter.datePreset) {
|
||||||
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
|
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek
|
||||||
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
|
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth
|
||||||
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
|
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear
|
||||||
null -> when {
|
null -> when {
|
||||||
minCreatedAt != null && maxCreatedAt != null ->
|
minCreatedAt != null && maxCreatedAt != null ->
|
||||||
strings.filterDateRange( // ← "X–Y"
|
strings.history.filterDateRange(
|
||||||
formatEpochShort(minCreatedAt),
|
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
|
||||||
formatEpochShort(maxCreatedAt)
|
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
|
||||||
)
|
)
|
||||||
minCreatedAt != null ->
|
minCreatedAt != null ->
|
||||||
strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
|
strings.history.filterDateFrom(
|
||||||
|
formatEpochShort(minCreatedAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
else ->
|
else ->
|
||||||
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
|
strings.history.filterDateUntil(
|
||||||
|
formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -479,7 +494,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearDate, // ← "Clear date filter"
|
contentDescription = strings.history.filterClearDate,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -489,11 +504,11 @@ private fun ActiveFilterChips(
|
|||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
onClick = onClearSearch,
|
onClick = onClearSearch,
|
||||||
label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
|
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historyClearSearch, // ← "Clear search"
|
contentDescription = strings.history.historyClearSearch,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isFailedStatus
|
||||||
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
import com.bitcointxoko.gudariwallet.util.isPendingStatus
|
||||||
|
|
||||||
internal fun formatEpochShort(epoch: Long): String =
|
|
||||||
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
|
|
||||||
.withZone(java.time.ZoneId.systemDefault())
|
|
||||||
.format(java.time.Instant.ofEpochSecond(epoch))
|
|
||||||
|
|
||||||
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
|
||||||
val formatter = java.time.format.DateTimeFormatter
|
|
||||||
.ofPattern("dd MMM yyyy, HH:mm")
|
|
||||||
.withZone(java.time.ZoneId.systemDefault())
|
|
||||||
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
|
|
||||||
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
|
|
||||||
}
|
|
||||||
// Fallback: only reached for payments not yet written through Room
|
|
||||||
return runCatching {
|
|
||||||
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
|
|
||||||
}.recoverCatching {
|
|
||||||
formatter.format(java.time.OffsetDateTime.parse(rawTime))
|
|
||||||
}.getOrDefault(rawTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isPendingStatus(status: String): Boolean =
|
|
||||||
status.lowercase() in setOf("pending", "in_flight", "inflight")
|
|
||||||
|
|
||||||
internal fun isFailedStatus(status: String): Boolean =
|
|
||||||
status.lowercase() in setOf("failed", "error", "expired")
|
|
||||||
|
|
||||||
// ── Composable helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
||||||
|
|||||||
@@ -5,23 +5,35 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.ContactSummary
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toPaymentFeedItem
|
||||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
private const val TAG = "HistoryViewModel"
|
private const val TAG = "HistoryViewModel"
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
@@ -29,20 +41,30 @@ class HistoryViewModel(
|
|||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
|
private val feedRepository : PaymentFeedRepository,
|
||||||
val fiatCurrency : StateFlow<String?>,
|
val fiatCurrency : StateFlow<String?>,
|
||||||
val fiatSatsPerUnit : StateFlow<Double?>
|
val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
// ── Sync manager (owns _state, currentOffset, loadJob, sync loop) ─────────
|
|
||||||
|
// ── Sync manager ──────────────────────────────────────────────────────────
|
||||||
|
// Owns the websocket/sync loop, pagination, and optimistic inserts.
|
||||||
|
// Still operates on PaymentRecord internally — untouched by this refactor.
|
||||||
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
private val syncManager: PaymentSyncManager = PaymentSyncManager(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
onNewPayments = { payments -> enricher.enrich(payments) }
|
onNewPayments = { payments -> enricher.enrich(payments) }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Optimistic insert (called after a successful send) ─────────────────────
|
// ── Optimistic insert (called after a successful send) ─────────────────────
|
||||||
fun primePayment(record: PaymentRecord) {
|
fun primePayment(record: PaymentRecord) {
|
||||||
syncManager.primePayment(record)
|
syncManager.primePayment(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Enricher ──────────────────────────────────────────────────────────────
|
||||||
|
// Resolves pubkey/alias post-send and writes back into NodeAliasCache,
|
||||||
|
// which feeds into the feedRepository.observeFeedItems() combine.
|
||||||
private val enricher: PaymentEnricher = PaymentEnricher(
|
private val enricher: PaymentEnricher = PaymentEnricher(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
@@ -52,91 +74,142 @@ class HistoryViewModel(
|
|||||||
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
|
syncManager.updateEnrichedPayment(paymentHash, pubkey, alias)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Sync status ───────────────────────────────────────────────────────────
|
||||||
|
// Exposes Loading / Error / Success shell (isRefreshing, canLoadMore).
|
||||||
|
// Does NOT carry payment data — see feedItems below.
|
||||||
val state: StateFlow<HistoryState> = syncManager.state
|
val state: StateFlow<HistoryState> = syncManager.state
|
||||||
|
|
||||||
fun refresh() = syncManager.refresh()
|
fun refresh() = syncManager.refresh()
|
||||||
fun loadMore() = syncManager.loadMore(
|
fun loadMore() = syncManager.loadMore(
|
||||||
filteredCanLoadMore = { (filteredState.value as? HistoryState.Success)?.canLoadMore == true }
|
filteredCanLoadMore = {
|
||||||
|
(state.value as? HistoryState.Success)?.canLoadMore == true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Detail delegate ───────────────────────────────────────────────────────
|
// ── Detail delegate ────────────────────────────────────────────────────────
|
||||||
private val detailDelegate = PaymentDetailDelegate(
|
private val detailDelegate = PaymentDetailDelegate(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
scope = viewModelScope
|
scope = viewModelScope
|
||||||
)
|
)
|
||||||
val detailState: StateFlow<DetailState> = detailDelegate.state
|
val detailState: StateFlow<DetailState> = detailDelegate.state
|
||||||
fun loadDetail(checkingId: String, record: PaymentRecord? = null) =
|
fun loadDetail(paymentHash: String, record: PaymentRecord? = null) =
|
||||||
detailDelegate.load(checkingId, record)
|
detailDelegate.load(paymentHash, record)
|
||||||
|
|
||||||
fun clearDetail() = detailDelegate.clear()
|
fun clearDetail() = detailDelegate.clear()
|
||||||
|
|
||||||
// ── Filter state ──────────────────────────────────────────────────────────
|
// ── Filter state ───────────────────────────────────────────────────────────
|
||||||
private val _filter = MutableStateFlow(PaymentFilter())
|
private val _filter = MutableStateFlow(PaymentFilter())
|
||||||
val filter: StateFlow<PaymentFilter> = _filter
|
val filter: StateFlow<PaymentFilter> = _filter
|
||||||
|
|
||||||
fun setSearchQuery(q: String) { _filter.update { it.copy(searchQuery = q.trim()) } }
|
fun setSearchQuery(q: String) {
|
||||||
fun clearSearch() { _filter.update { it.copy(searchQuery = "") } }
|
_filter.update { it.copy(searchQuery = q.trim()) }
|
||||||
|
|
||||||
fun setDirectionFilter(direction: DirectionFilter) {
|
|
||||||
_filter.update { it.copy(direction = direction) }
|
|
||||||
}
|
}
|
||||||
fun toggleStatusFilter(status: StatusFilter) {
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_filter.update { it.copy(searchQuery = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDirectionFilter(d: DirectionFilter) {
|
||||||
|
_filter.update { it.copy(direction = d) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleStatusFilter(s: StatusFilter) {
|
||||||
_filter.update { f ->
|
_filter.update { f ->
|
||||||
val updated = if (status in f.statuses) f.statuses - status else f.statuses + status
|
val updated = if (s in f.statuses) f.statuses - s else f.statuses + s
|
||||||
f.copy(statuses = updated)
|
f.copy(statuses = updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleTypeFilter(type: String) {
|
fun toggleTypeFilter(type: String) {
|
||||||
_filter.update { f ->
|
_filter.update { f ->
|
||||||
val updated = if (type in f.types) f.types - type else f.types + type
|
val updated = if (type in f.types) f.types - type else f.types + type
|
||||||
f.copy(types = updated)
|
f.copy(types = updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAmountFilter(min: Long?, max: Long?) {
|
fun setAmountFilter(min: Long?, max: Long?) {
|
||||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyDatePreset(preset: DatePreset) {
|
fun applyDatePreset(preset: DatePreset) {
|
||||||
val (min, max) = preset.toEpochSecondRange() // ← delegate to PaymentFilter.kt
|
val (min, max) = preset.toEpochSecondRange()
|
||||||
setDateFilter(min, max, preset)
|
setDateFilter(min, max, preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearDateFilter() {
|
fun clearDateFilter() {
|
||||||
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
_filter.update { it.copy(minCreatedAt = null, maxCreatedAt = null, datePreset = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findPayment(checkingId: String): PaymentRecord? {
|
fun toggleContactFilter(contactId: String) {
|
||||||
val fromFiltered = (filteredState.value as? HistoryState.Success)
|
_filter.update { f ->
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
val updated = if (contactId in f.contactIds)
|
||||||
if (fromFiltered != null) return fromFiltered
|
f.contactIds - contactId else f.contactIds + contactId
|
||||||
return (state.value as? HistoryState.Success)
|
f.copy(contactIds = updated)
|
||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearContactFilter() {
|
||||||
|
_filter.update { it.copy(contactIds = emptySet()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Room-backed query results ──────────────────────────────────────────────
|
// ── Room-backed query results ──────────────────────────────────────────────
|
||||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
// Null = use the live feed stream.
|
||||||
|
// Non-null = a search / contact-ID Room query has run and its results
|
||||||
|
// override the live stream until the filter is cleared.
|
||||||
|
private val _roomResults = MutableStateFlow<List<PaymentFeedItem>?>(null)
|
||||||
|
|
||||||
// ── Derived state ─────────────────────────────────────────────────────────
|
// ── Feed items — single source of truth for the UI list ───────────────────
|
||||||
|
// Emits a freshly filtered List<PaymentFeedItem> whenever any upstream
|
||||||
/** Distinct non-null tags present in the currently loaded list. */
|
// source changes: payments DB, contact links, alias cache, fiat rate,
|
||||||
val availableTypes: StateFlow<List<String>> = syncManager.state
|
// active filter, or Room query override.
|
||||||
.map { s ->
|
val feedItems: StateFlow<List<PaymentFeedItem>> = combine(
|
||||||
if (s !is HistoryState.Success) emptyList()
|
feedRepository.observeFeedItems(),
|
||||||
else s.payments.mapNotNull { it.extra?.tag }.distinct().sorted()
|
_filter,
|
||||||
|
_roomResults
|
||||||
|
) { feed, f, roomResults ->
|
||||||
|
Triple(feed, f, roomResults)
|
||||||
}
|
}
|
||||||
|
.distinctUntilChanged { old, new ->
|
||||||
|
// If room results are active on both sides and haven't changed, suppress
|
||||||
|
val oldRoom = old.third
|
||||||
|
val newRoom = new.third
|
||||||
|
if (oldRoom != null && newRoom != null && oldRoom === newRoom && old.second == new.second) {
|
||||||
|
return@distinctUntilChanged true // same room results, same filter — skip
|
||||||
|
}
|
||||||
|
false // otherwise let through
|
||||||
|
}
|
||||||
|
.map { (feed, f, roomResults) ->
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
roomResults?.applyFeedFilters(
|
||||||
|
f.copy(searchQuery = "", contactIds = emptySet())
|
||||||
|
) ?: feed.applyFeedFilters(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
val filteredState = combine(syncManager.state, _filter, _roomResults) { s, f, roomResults ->
|
// ── Available type filter chips ────────────────────────────────────────────
|
||||||
if (s !is HistoryState.Success) return@combine s
|
val availableTypes: StateFlow<List<String>> = feedItems
|
||||||
if (roomResults == null) {
|
.map { items -> items.mapNotNull { it.tag }.distinct().sorted() }
|
||||||
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||||
} else {
|
|
||||||
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
|
||||||
}
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
|
||||||
|
|
||||||
// ── In-memory filter logic ────────────────────────────────────────────────
|
// ── Find a single item by paymentHash ─────────────────────────────────────
|
||||||
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
// paymentHash supersedes checkingId as the stable unique identifier.
|
||||||
|
// Used by PaymentDetailScreen to retrieve a feed item without a DB round-trip.
|
||||||
|
fun findPayment(paymentHash: String): PaymentFeedItem? =
|
||||||
|
feedItems.value.firstOrNull { it.paymentHash == paymentHash }
|
||||||
|
|
||||||
|
// ── In-memory filter logic ─────────────────────────────────────────────────
|
||||||
|
// All filter keys map directly to PaymentFeedItem fields — no extra
|
||||||
|
// resolution needed (contact names, alias, fiat are already embedded).
|
||||||
|
private fun List<PaymentFeedItem>.applyInMemoryFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||||
distinctBy { it.paymentHash }
|
distinctBy { it.paymentHash }
|
||||||
.let { list ->
|
.let { list ->
|
||||||
when (f.direction) {
|
when (f.direction) {
|
||||||
@@ -145,59 +218,251 @@ class HistoryViewModel(
|
|||||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.PENDING -> item.isPending
|
||||||
|
StatusFilter.FAILED -> item.isFailed
|
||||||
|
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.let { list ->
|
.let { list ->
|
||||||
if (f.types.isEmpty()) list
|
if (f.types.isEmpty()) list
|
||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.tag in f.types }
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val min = f.minAmountSat;
|
||||||
|
val max = f.maxAmountSat
|
||||||
|
if (min == null && max == null) list
|
||||||
|
else list.filter { item ->
|
||||||
|
(min == null || item.amountSat >= min) &&
|
||||||
|
(max == null || item.amountSat <= max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val min = f.minCreatedAt;
|
||||||
|
val max = f.maxCreatedAt
|
||||||
|
if (min == null && max == null) list
|
||||||
|
else list.filter { item ->
|
||||||
|
val ts = item.createdAt ?: item.time
|
||||||
|
ts != null &&
|
||||||
|
(min == null || ts >= min) &&
|
||||||
|
(max == null || ts <= max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
val q = f.searchQuery
|
||||||
|
if (q.isBlank()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
item.memo?.contains(q, ignoreCase = true) == true ||
|
||||||
|
item.contactName?.contains(q, ignoreCase = true) == true ||
|
||||||
|
item.paymentHash.contains(q, ignoreCase = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Contact display names (for filter chips) ───────────────────────────────
|
||||||
|
// Used to render contact names in active filter chips on the UI.
|
||||||
|
// Payment row contact names are embedded in PaymentFeedItem.contactName.
|
||||||
|
val contactDisplayNames: StateFlow<Map<String, String>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.map { contacts ->
|
||||||
|
contacts.associate { c ->
|
||||||
|
c.contact.id to (c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: "Unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Per-payment contact queries ────────────────────────────────────────────
|
||||||
|
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
|
||||||
|
contactRepo.observeContactsForPayment(paymentHash)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Contact assignment ─────────────────────────────────────────────────────
|
||||||
|
fun assignContact(paymentHash: String, contactId: String) {
|
||||||
|
Timber.tag(TAG).d("CONTACT [ASSIGN ] paymentHash=$paymentHash contactId=$contactId")
|
||||||
|
viewModelScope.launch { contactRepo.linkPaymentToContact(paymentHash, contactId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unassignContact(paymentHash: String, contactId: String) {
|
||||||
|
viewModelScope.launch { contactRepo.unlinkPaymentFromContact(paymentHash, contactId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveContactAndAssign(
|
||||||
|
paymentHash: String,
|
||||||
|
name: String,
|
||||||
|
addressType: PaymentAddressType?,
|
||||||
|
address: String?
|
||||||
|
) {
|
||||||
|
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] paymentHash=$paymentHash name=$name")
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
val contact = contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
Timber.tag(TAG).d("CONTACT [CREATE+ASSIGN] created contactId=${contact.id} → linking")
|
||||||
|
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
init {
|
init {
|
||||||
syncManager.init()
|
syncManager.init()
|
||||||
|
|
||||||
|
// ── Room query path ────────────────────────────────────────────────────
|
||||||
|
// Activated when the filter requires DB-level text search or contact-ID
|
||||||
|
// lookup. Results are stored in _roomResults and override the live feed
|
||||||
|
// stream in feedItems. Cleared back to null when no Room query is needed.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
_filter.map { it.searchQuery }.debounce(300),
|
_filter.map { it.searchQuery }.debounce(300.milliseconds),
|
||||||
_filter
|
_filter
|
||||||
) { _, f -> f }
|
) { _, f -> f }
|
||||||
|
.distinctUntilChanged()
|
||||||
.collect { f ->
|
.collect { f ->
|
||||||
if (!f.needsRoomQuery()) {
|
if (!f.needsRoomQuery()) {
|
||||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
Timber.tag(TAG).d("FILTER [IN-MEMORY] using live feed")
|
||||||
_roomResults.value = null
|
_roomResults.value = null
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the contact summaries once per query — no new DAO needed.
|
||||||
|
// Groups PaymentContactSummary by paymentHash so toFeedItem() can
|
||||||
|
// resolve contact names without per-row DB calls.
|
||||||
|
val contactSnapshot: Map<String, List<ContactSummary>> =
|
||||||
|
contactRepo.observePaymentContactSummaries()
|
||||||
|
.first()
|
||||||
|
.groupBy(
|
||||||
|
keySelector = { it.paymentHash },
|
||||||
|
valueTransform = {
|
||||||
|
ContactSummary(
|
||||||
|
id = it.contactId,
|
||||||
|
displayName = it.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Explicit contact-ID filter (highest priority) ──────────
|
||||||
|
if (f.contactIds.isNotEmpty()) {
|
||||||
|
val contactHashes = contactRepo
|
||||||
|
.findPaymentHashesByContactIds(f.contactIds.toList())
|
||||||
|
_roomResults.value = if (contactHashes.isNotEmpty()) {
|
||||||
|
paymentCache.queryByPaymentHashes(contactHashes, f)
|
||||||
|
.map { it.toFeedItem(contactSnapshot) }
|
||||||
|
.also {
|
||||||
|
Timber.tag(TAG).d(
|
||||||
|
"FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results"
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
emptyList()
|
||||||
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
|
}
|
||||||
"amt=${f.minAmountSat}–${f.maxAmountSat} " +
|
return@collect
|
||||||
"date=${f.minCreatedAt}–${f.maxCreatedAt}")
|
}
|
||||||
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
|
||||||
|
// ── Text search ────────────────────────────────────────────
|
||||||
|
val baseRecords = paymentCache.queryAll(f.searchQuery, f).toMutableList()
|
||||||
|
|
||||||
|
if (f.searchQuery.isNotBlank()) {
|
||||||
|
val contactHashes = contactRepo
|
||||||
|
.findPaymentHashesByContactName(f.searchQuery)
|
||||||
|
if (contactHashes.isNotEmpty()) {
|
||||||
|
val existingIds = baseRecords.map { it.checkingId }.toSet()
|
||||||
|
baseRecords += paymentCache
|
||||||
|
.queryByPaymentHashes(contactHashes, f)
|
||||||
|
.filter { it.checkingId !in existingIds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_roomResults.value = baseRecords
|
||||||
|
.map { it.toFeedItem(contactSnapshot) }
|
||||||
|
.also {
|
||||||
|
Timber.tag(TAG).d(
|
||||||
|
"FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── WebSocket payment event listener ───────────────────────────────────
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
WalletNotificationService.paymentEvents.collect { event ->
|
WalletNotificationService.paymentEvents.collect { event ->
|
||||||
Timber.d("PAYMENTS [WS EVENT ] incoming event ${event.paymentHash} — refreshing list")
|
Timber.tag(TAG).d("PAYMENTS [WS EVENT] ${event.paymentHash} — refreshing")
|
||||||
syncManager.refresh()
|
syncManager.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Private helper ─────────────────────────────────────────────────────────
|
||||||
|
// Converts a Room query PaymentRecord into a PaymentFeedItem for _roomResults.
|
||||||
|
// Contact links, alias, and fiat are intentionally left empty/null here —
|
||||||
|
// applyInMemoryFilters only needs paymentHash, amountSat, tag, memo, and
|
||||||
|
// timestamps. Full enrichment is provided by feedRepository.observeFeedItems()
|
||||||
|
// for all items that pass through the live feed path.
|
||||||
|
private fun PaymentRecord.toFeedItem(
|
||||||
|
contactSnapshot: Map<String, List<ContactSummary>> = emptyMap()
|
||||||
|
): PaymentFeedItem =
|
||||||
|
this.toPaymentFeedItem(
|
||||||
|
linkedContacts = contactSnapshot[paymentHash] ?: emptyList(),
|
||||||
|
resolvedAlias = pubkey?.let { aliasRepo.aliases.value[it] },
|
||||||
|
fiatCurrency = fiatCurrency.value,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
// ── Factory ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class HistoryViewModelFactory(
|
class HistoryViewModelFactory(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
|
private val feedRepository : PaymentFeedRepository,
|
||||||
private val fiatCurrency : StateFlow<String?>,
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
private val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) {
|
||||||
|
"Unknown ViewModel class: ${modelClass.name}"
|
||||||
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return HistoryViewModel(
|
return HistoryViewModel(
|
||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
|
feedRepository = feedRepository,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
) as T
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-8
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui.history
|
|||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
@@ -15,6 +16,7 @@ private const val TAG = "PaymentDetailDelegate"
|
|||||||
class PaymentDetailDelegate(
|
class PaymentDetailDelegate(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val scope : CoroutineScope
|
private val scope : CoroutineScope
|
||||||
) {
|
) {
|
||||||
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
|
private val _state = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||||
@@ -22,38 +24,67 @@ class PaymentDetailDelegate(
|
|||||||
|
|
||||||
fun load(checkingId: String, record: PaymentRecord? = null) {
|
fun load(checkingId: String, record: PaymentRecord? = null) {
|
||||||
paymentCache.getCachedDetail(checkingId)?.let {
|
paymentCache.getCachedDetail(checkingId)?.let {
|
||||||
Timber.d("DETAIL [HIT] $checkingId — instant from cache")
|
Timber.tag(TAG).d("DETAIL [HIT] $checkingId — instant from cache")
|
||||||
_state.value = DetailState.Success(it)
|
_state.value = DetailState.Success(it)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.value = if (record != null) {
|
_state.value = if (record != null) {
|
||||||
Timber.d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
Timber.tag(TAG).d("DETAIL [PAR] $checkingId — partial from PaymentRecord")
|
||||||
DetailState.Partial(record)
|
DetailState.Partial(record)
|
||||||
} else {
|
} else {
|
||||||
Timber.d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
Timber.tag(TAG).d("DETAIL [LOD] $checkingId — no record, showing spinner")
|
||||||
DetailState.Loading
|
DetailState.Loading
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
val fromDb = paymentCache.getDetailFromDb(checkingId)
|
||||||
if (fromDb != null) {
|
if (fromDb != null) {
|
||||||
paymentCache.saveDetail(checkingId, fromDb)
|
val record = fromDb.details
|
||||||
_state.value = DetailState.Success(fromDb)
|
val enriched = if (
|
||||||
Timber.d("DETAIL [DB] $checkingId — served from Room")
|
record != null &&
|
||||||
|
record.pubkey.isNullOrBlank() &&
|
||||||
|
!record.bolt11.isNullOrBlank()
|
||||||
|
) {
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — decoding bolt11 for pubkey")
|
||||||
|
val pubkey = runCatching {
|
||||||
|
repo.decodeBolt11(record.bolt11!!).payee
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
if (pubkey != null) {
|
||||||
|
val alias = runCatching {
|
||||||
|
aliasRepo.resolve(pubkey)
|
||||||
|
}.getOrNull()
|
||||||
|
paymentCache.updatePubkeyAlias(record.paymentHash, pubkey, alias)
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — pubkey=$pubkey alias=$alias")
|
||||||
|
fromDb.copy(details = record.copy(pubkey = pubkey, alias = alias))
|
||||||
|
} else {
|
||||||
|
Timber.tag(TAG).d("DETAIL [ENRICH] $checkingId — bolt11 decode failed")
|
||||||
|
fromDb
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fromDb
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentCache.saveDetail(checkingId, enriched)
|
||||||
|
_state.value = DetailState.Success(enriched)
|
||||||
|
Timber.tag(TAG).d("DETAIL [DB] $checkingId — served from Room")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
runCatching { repo.getPaymentDetail(checkingId) }
|
runCatching { repo.getPaymentDetail(checkingId) }
|
||||||
.onSuccess { detail ->
|
.onSuccess { detail ->
|
||||||
Timber.d("DETAIL [NET] $checkingId — fetched from network")
|
Timber.tag(TAG).d("DETAIL [NET] $checkingId — fetched from network")
|
||||||
paymentCache.saveDetail(checkingId, detail)
|
paymentCache.saveDetail(checkingId, detail)
|
||||||
_state.value = DetailState.Success(detail)
|
_state.value = DetailState.Success(detail)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Timber.w("DETAIL [ERR] $checkingId — ${e.message}")
|
Timber.tag(TAG).w("DETAIL [ERR] $checkingId — ${e.message}")
|
||||||
if (_state.value !is DetailState.Partial) {
|
if (_state.value !is DetailState.Partial) {
|
||||||
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
|
_state.value = DetailState.Error(e.message ?: "Failed to load payment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() { _state.value = DetailState.Idle }
|
fun clear() { _state.value = DetailState.Idle }
|
||||||
}
|
}
|
||||||
+235
-59
@@ -6,24 +6,30 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.outlined.ContentCopy
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -33,8 +39,10 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
@@ -42,43 +50,88 @@ import androidx.compose.ui.platform.LocalClipboard
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.toBaseRecord
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.ContactAvatar
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── Detail screen ────────────────────────────────────────────────────────────
|
// ── Detail screen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PaymentDetailScreen(
|
fun PaymentDetailScreen(
|
||||||
payment : PaymentRecord,
|
item : PaymentFeedItem, // ← was: payment: PaymentRecord
|
||||||
vm : HistoryViewModel,
|
vm : HistoryViewModel,
|
||||||
onBack : () -> Unit
|
onBack : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val detailState by vm.detailState.collectAsState()
|
val detailState by vm.detailState.collectAsState()
|
||||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
|
||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
// fiatCurrency and fiatSatsPerUnit are now embedded in PaymentFeedItem —
|
||||||
|
// no need to collect them from the ViewModel separately.
|
||||||
|
val fiatCurrency = item.fiatCurrency
|
||||||
|
val fiatSatsPerUnit = item.fiatSatsPerUnit
|
||||||
|
|
||||||
|
// Load detail and clear on exit — keyed on paymentHash (supersedes checkingId)
|
||||||
|
LaunchedEffect(item.paymentHash) { vm.loadDetail(item.paymentHash) }
|
||||||
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||||
|
|
||||||
|
// Enrich the base PaymentFeedItem's backing record with the detail response.
|
||||||
|
// PaymentDetailContent still operates on PaymentRecord for technical fields.
|
||||||
|
// enriched falls back through partial → base PaymentRecord via toDomain()
|
||||||
|
// if the detail fetch is still in flight or has failed.
|
||||||
val enriched: PaymentRecord = when (val s = detailState) {
|
val enriched: PaymentRecord = when (val s = detailState) {
|
||||||
is DetailState.Success -> {
|
is DetailState.Success -> {
|
||||||
val detail = s.detail.details
|
val detail = s.detail.details
|
||||||
when {
|
when {
|
||||||
detail == null -> payment
|
detail == null -> item.toBaseRecord()
|
||||||
else -> detail.copy(
|
else -> detail.copy(
|
||||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: item.memo,
|
||||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: item.bolt11,
|
||||||
|
pubkey = detail.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||||
|
alias = detail.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DetailState.Partial -> s.record
|
is DetailState.Partial -> s.record.let { r ->
|
||||||
else -> payment
|
r.copy(
|
||||||
|
pubkey = r.pubkey?.takeIf { it.isNotBlank() } ?: item.pubkey,
|
||||||
|
alias = r.alias?.takeIf { it.isNotBlank() } ?: item.alias
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> item.toBaseRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Contact state ──────────────────────────────────────────────────────────
|
||||||
|
val linkedContacts by remember(item.paymentHash) {
|
||||||
|
vm.contactsForPayment(item.paymentHash)
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var showContactPicker by remember { mutableStateOf(false) }
|
||||||
|
var showCreateContact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Best-effort lnAddress to pre-fill CreateContactSheet
|
||||||
|
val paymentLnAddress = remember(enriched) {
|
||||||
|
enriched.extra?.comment
|
||||||
|
?.takeIf { it.contains("@") && !it.contains(" ") }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -86,15 +139,15 @@ fun PaymentDetailScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment"
|
if (item.isOutgoing) strings.history.detailOutgoingTitle
|
||||||
else strings.detailIncomingTitle // ← "Incoming payment"
|
else strings.history.detailIncomingTitle
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = strings.detailBack // ← "Back"
|
contentDescription = strings.history.detailBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +169,7 @@ fun PaymentDetailScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.detailCouldNotLoad, // ← "Could not load full details"
|
text = strings.history.detailCouldNotLoad,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
@@ -125,7 +178,12 @@ fun PaymentDetailScreen(
|
|||||||
PaymentDetailContent(
|
PaymentDetailContent(
|
||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(item.paymentHash, contactId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,24 +193,71 @@ fun PaymentDetailScreen(
|
|||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(item.paymentHash, contactId)
|
||||||
|
},
|
||||||
modifier = Modifier.padding(padding)
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact picker sheet ───────────────────────────────────────────────────
|
||||||
|
if (showContactPicker) {
|
||||||
|
ContactPickerSheet(
|
||||||
|
contacts = allContacts,
|
||||||
|
onContactSelected = { contactId ->
|
||||||
|
vm.assignContact(item.paymentHash, contactId)
|
||||||
|
showContactPicker = false
|
||||||
|
},
|
||||||
|
onCreateNew = {
|
||||||
|
showContactPicker = false
|
||||||
|
showCreateContact = true
|
||||||
|
},
|
||||||
|
onDismiss = { showContactPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create & assign contact sheet ──────────────────────────────────────────
|
||||||
|
if (showCreateContact) {
|
||||||
|
val addressType = if (paymentLnAddress != null)
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddressType = addressType,
|
||||||
|
initialAddress = paymentLnAddress,
|
||||||
|
onSave = { name, type, address ->
|
||||||
|
vm.saveContactAndAssign(item.paymentHash, name, type, address)
|
||||||
|
showCreateContact = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateContact = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail content ───────────────────────────────────────────────────────────
|
// ── Detail content ─────────────────────────────────────────────────────────────
|
||||||
|
// Receives the enriched PaymentRecord from detailState — all technical fields
|
||||||
|
// (bolt11, preimage, extra, pubkey, alias, amountMsat, feeMsat) come from here.
|
||||||
|
// fiatCurrency and fiatSatsPerUnit are passed in from the outer screen (via item).
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaymentDetailContent(
|
private fun PaymentDetailContent(
|
||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
|
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||||
|
onAssignContact : () -> Unit,
|
||||||
|
onUnassignContact : (contactId: String) -> Unit,
|
||||||
modifier : Modifier = Modifier
|
modifier : Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
val amountColor = paymentAmountColor(
|
||||||
|
isPending = payment.pending,
|
||||||
|
isFailed = payment.status == "failed",
|
||||||
|
isOutgoing = payment.isOutgoing
|
||||||
|
)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
@@ -178,7 +283,7 @@ private fun PaymentDetailContent(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// ── Hero amount ──────────────────────────────────────────────────────
|
// ── Hero amount ────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -206,12 +311,9 @@ private fun PaymentDetailContent(
|
|||||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||||
)
|
)
|
||||||
// Build the optional parts separately so the lambda stays clean
|
|
||||||
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
||||||
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
||||||
val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
|
val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
|
||||||
payment.feeSat, fiatPart, ppmPart
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = feeLabel,
|
text = feeLabel,
|
||||||
@@ -225,39 +327,129 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Basic info ───────────────────────────────────────────────────────
|
// ── Contact section ────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionDetails) { // ← "Details"
|
if (linkedContacts.isEmpty()) {
|
||||||
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date"
|
OutlinedButton(
|
||||||
|
onClick = onAssignContact,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Assign contact")
|
||||||
|
// Text(strings.history.detailAssignContact)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Contacts",
|
||||||
|
// text = strings.history.detailContactsTitle,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onAssignContact,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = "Edit",
|
||||||
|
// contentDescription = strings.history.detailEditContacts,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
linkedContacts.forEachIndexed { index, contact ->
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ContactAvatar(name = name)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { onUnassignContact(contact.id) },
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Remove",
|
||||||
|
// contentDescription = strings.history.detailRemoveContact,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index < linkedContacts.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic info ─────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.history.detailSectionDetails) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
strings.detailRowMemo, // ← "Memo"
|
strings.history.detailRowDate,
|
||||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—"
|
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
DetailRow(
|
||||||
|
strings.history.detailRowMemo,
|
||||||
|
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
|
||||||
)
|
)
|
||||||
if (payment.extra?.tag != null) {
|
if (payment.extra?.tag != null) {
|
||||||
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type"
|
DetailRow(strings.history.detailRowType, payment.extra.tag)
|
||||||
}
|
}
|
||||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||||
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment"
|
DetailRow(strings.history.detailRowComment, payment.extra.comment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
// ── Success action (LNURL) ─────────────────────────────────────────────
|
||||||
payment.extra?.successAction?.let { action ->
|
payment.extra?.successAction?.let { action ->
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action"
|
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||||
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message"
|
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||||
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL"
|
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||||
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description"
|
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Technical ────────────────────────────────────────────────────────
|
// ── Technical ─────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical"
|
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowPaymentHash, // ← "Payment Hash"
|
label = strings.history.detailRowPaymentHash,
|
||||||
value = payment.paymentHash,
|
value = payment.paymentHash,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
@@ -271,7 +463,7 @@ private fun PaymentDetailContent(
|
|||||||
)
|
)
|
||||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowPreimage, // ← "Preimage"
|
label = strings.history.detailRowPreimage,
|
||||||
value = payment.preimage,
|
value = payment.preimage,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
@@ -288,7 +480,7 @@ private fun PaymentDetailContent(
|
|||||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||||
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowDestination, // ← "Destination"
|
label = strings.history.detailRowDestination,
|
||||||
value = label,
|
value = label,
|
||||||
valueColor = MaterialTheme.colorScheme.primary,
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||||
@@ -296,29 +488,13 @@ private fun PaymentDetailContent(
|
|||||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(
|
CopyIconButton(label = "node_id", value = pubkey)
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(
|
|
||||||
ClipEntry(ClipData.newPlainText("node_id", pubkey))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.ContentCopy,
|
|
||||||
contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!payment.bolt11.isNullOrBlank()) {
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowBolt11, // ← "BOLT11"
|
label = strings.history.detailRowBolt11,
|
||||||
value = payment.bolt11,
|
value = payment.bolt11,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.history
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
@@ -14,7 +15,8 @@ data class PaymentFilter(
|
|||||||
val maxAmountSat : Long? = null,
|
val maxAmountSat : Long? = null,
|
||||||
val minCreatedAt : Long? = null,
|
val minCreatedAt : Long? = null,
|
||||||
val maxCreatedAt : Long? = null,
|
val maxCreatedAt : Long? = null,
|
||||||
val datePreset : DatePreset? = null
|
val datePreset : DatePreset? = null,
|
||||||
|
val contactIds : Set<String> = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||||
@@ -29,12 +31,16 @@ val PaymentFilter.isActive: Boolean
|
|||||||
|| maxAmountSat != null
|
|| maxAmountSat != null
|
||||||
|| minCreatedAt != null
|
|| minCreatedAt != null
|
||||||
|| maxCreatedAt != null
|
|| maxCreatedAt != null
|
||||||
|
|| contactIds.isNotEmpty()
|
||||||
|
|
||||||
fun PaymentFilter.needsRoomQuery(): Boolean =
|
fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||||
searchQuery.isNotBlank() ||
|
searchQuery.isNotBlank() ||
|
||||||
statuses.isNotEmpty() ||
|
statuses.isNotEmpty() ||
|
||||||
minAmountSat != null || maxAmountSat != null ||
|
minAmountSat != null ||
|
||||||
minCreatedAt != null || maxCreatedAt != null
|
maxAmountSat != null ||
|
||||||
|
minCreatedAt != null ||
|
||||||
|
maxCreatedAt != null ||
|
||||||
|
contactIds.isNotEmpty()
|
||||||
|
|
||||||
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
||||||
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
||||||
@@ -60,3 +66,51 @@ fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter): List<PaymentReco
|
|||||||
if (f.types.isEmpty()) list
|
if (f.types.isEmpty()) list
|
||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.extra?.tag in f.types }
|
||||||
}
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { record ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.COMPLETED -> !record.pending && record.status == "complete"
|
||||||
|
StatusFilter.PENDING -> record.pending
|
||||||
|
StatusFilter.FAILED -> record.status == "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<PaymentFeedItem>.applyFeedFilters(f: PaymentFilter): List<PaymentFeedItem> =
|
||||||
|
distinctBy { it.paymentHash }
|
||||||
|
.let { list ->
|
||||||
|
when (f.direction) {
|
||||||
|
DirectionFilter.ALL -> list
|
||||||
|
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||||
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.types.isEmpty()) list
|
||||||
|
else list.filter { it.tag in f.types } // ← uses pre-extracted tag, no deserialization
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.statuses.isEmpty()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
f.statuses.any { status ->
|
||||||
|
when (status) {
|
||||||
|
StatusFilter.COMPLETED -> !item.isPending && !item.isFailed
|
||||||
|
StatusFilter.PENDING -> item.isPending
|
||||||
|
StatusFilter.FAILED -> item.isFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.let { list ->
|
||||||
|
if (f.searchQuery.isBlank()) list
|
||||||
|
else list.filter { item ->
|
||||||
|
item.memo?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.contactName?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.alias?.contains(f.searchQuery, ignoreCase = true) == true
|
||||||
|
|| item.paymentHash.startsWith(f.searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,29 +22,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.model.PaymentFeedItem
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PaymentRow(
|
internal fun PaymentRow(
|
||||||
payment : PaymentRecord,
|
item : PaymentFeedItem,
|
||||||
fiatCurrency : String?,
|
|
||||||
fiatSatsPerUnit : Double?,
|
|
||||||
onClick : () -> Unit
|
onClick : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val isPending = isPendingStatus(payment.status)
|
|
||||||
val isFailed = isFailedStatus(payment.status)
|
|
||||||
val semantic = semanticColors()
|
val semantic = semanticColors()
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
|
||||||
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
|
||||||
else Icons.Default.ArrowDownward
|
|
||||||
|
|
||||||
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
val amountColor = paymentAmountColor(
|
||||||
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
isPending = item.isPending,
|
||||||
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
isFailed = item.isFailed,
|
||||||
|
isOutgoing = item.isOutgoing
|
||||||
|
)
|
||||||
|
val icon = if (item.isOutgoing) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward
|
||||||
|
|
||||||
|
val fiatLine: String? = remember(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency) {
|
||||||
|
if (item.fiatSatsPerUnit != null && item.fiatCurrency != null && item.amountSat > 0)
|
||||||
|
formatFiatForSats(item.amountSat, item.fiatSatsPerUnit, item.fiatCurrency)
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +59,8 @@ internal fun PaymentRow(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent"
|
contentDescription = if (item.isOutgoing) strings.history.paymentRowSentCD
|
||||||
else strings.paymentRowReceivedCD, // ← "Received"
|
else strings.history.paymentRowReceivedCD,
|
||||||
tint = amountColor,
|
tint = amountColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
@@ -66,34 +68,36 @@ internal fun PaymentRow(
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = payment.memo?.takeIf { it.isNotBlank() }
|
text = item.contactName // contact name first
|
||||||
?: strings.paymentRowNoMemo, // ← "No memo"
|
?: item.memo?.takeIf { it.isNotBlank() } // then memo
|
||||||
|
?: strings.history.paymentRowNoMemo, // then fallback
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
val formattedTime = remember(item.createdAt ?: item.time) {
|
||||||
formatTimestamp(payment.createdAt, payment.time)
|
formatTimestamp(item.createdAt, item.time?.toString() ?: "", strings.today, strings.yesterday)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
val statusLabel = when {
|
val statusLabel = when {
|
||||||
isPending -> strings.paymentRowPending
|
item.isPending -> strings.history.paymentRowPending
|
||||||
isFailed -> strings.paymentRowFailed
|
item.isFailed -> strings.history.paymentRowFailed
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val statusColor = when {
|
val statusColor = when {
|
||||||
isPending -> semantic.warningContainer
|
item.isPending -> semantic.warningContainer
|
||||||
isFailed -> semantic.errorContainer
|
item.isFailed -> semantic.errorContainer
|
||||||
else -> semantic.successContainer // fallback, won't be used
|
else -> semantic.successContainer
|
||||||
}
|
}
|
||||||
val statusContentColor = when {
|
val statusContentColor = when {
|
||||||
isPending -> semantic.onWarningContainer
|
item.isPending -> semantic.onWarningContainer
|
||||||
isFailed -> semantic.onErrorContainer
|
item.isFailed -> semantic.onErrorContainer
|
||||||
else -> semantic.onSuccessContainer
|
else -> semantic.onSuccessContainer
|
||||||
}
|
}
|
||||||
if (statusLabel != null) {
|
if (statusLabel != null) {
|
||||||
@@ -113,10 +117,25 @@ internal fun PaymentRow(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
AmountWithFiatColumn(
|
AmountWithFiatColumn(
|
||||||
amountSat = payment.amountSat,
|
amountSat = item.amountSat,
|
||||||
isOutgoing = payment.isOutgoing,
|
isOutgoing = item.isOutgoing,
|
||||||
amountColor = amountColor,
|
amountColor = amountColor,
|
||||||
fiatLine = fiatLine
|
fiatLine = fiatLine
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun paymentAmountColor(
|
||||||
|
isPending : Boolean,
|
||||||
|
isFailed : Boolean,
|
||||||
|
isOutgoing : Boolean
|
||||||
|
): androidx.compose.ui.graphics.Color {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
return when {
|
||||||
|
isPending -> semantic.onWarningContainer
|
||||||
|
isFailed -> MaterialTheme.colorScheme.error
|
||||||
|
isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> semantic.onSuccessContainer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ class PaymentSyncManager(
|
|||||||
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
|
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
|
||||||
|
|
||||||
if (reachedOverlap || reachedEnd) {
|
if (reachedOverlap || reachedEnd) {
|
||||||
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " +
|
Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
|
||||||
if (reachedOverlap) "stopped at known record" else "reached end of history")
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,45 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
|
|||||||
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoTitle, // ← "What can I search for?"
|
text = strings.history.searchInfoTitle, // ← "What can I search for?"
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoSubtitle, // ← "Type any part of the following…"
|
text = strings.history.searchInfoSubtitle, // ← "Type any part of the following…"
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoMemoTitle, // ← "Memo"
|
title = strings.history.searchInfoMemoTitle, // ← "Memo"
|
||||||
description = strings.searchInfoMemoDesc
|
description = strings.history.searchInfoMemoDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoInvoiceTitle, // ← "Invoice"
|
title = strings.history.searchInfoInvoiceTitle, // ← "Invoice"
|
||||||
description = strings.searchInfoInvoiceDesc
|
description = strings.history.searchInfoInvoiceDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoHashTitle, // ← "Hash"
|
title = strings.history.searchInfoHashTitle, // ← "Hash"
|
||||||
description = strings.searchInfoHashDesc
|
description = strings.history.searchInfoHashDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoPreimageTitle, // ← "Preimage"
|
title = strings.history.searchInfoPreimageTitle, // ← "Preimage"
|
||||||
description = strings.searchInfoPreimageDesc
|
description = strings.history.searchInfoPreimageDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoNodeAliasTitle, // ← "Node alias"
|
title = strings.history.searchInfoNodeAliasTitle, // ← "Node alias"
|
||||||
description = strings.searchInfoNodeAliasDesc
|
description = strings.history.searchInfoNodeAliasDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
|
title = strings.history.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
|
||||||
description = strings.searchInfoNodeIdDesc
|
description = strings.history.searchInfoNodeIdDesc
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoFooter, // ← "You can paste a full value…"
|
text = strings.history.searchInfoFooter, // ← "You can paste a full value…"
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.lock
|
||||||
|
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Fingerprint
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.LockStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as FragmentActivity
|
||||||
|
val strings = LocalAppStrings.current.lock
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
showBiometricPrompt(activity, onUnlocked, strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.appName,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.authenticateToContinue,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(strings.unlockButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPrompt(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
onUnlocked: () -> Unit,
|
||||||
|
strings: LockStrings
|
||||||
|
) {
|
||||||
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
|
||||||
|
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
onUnlocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val prompt = BiometricPrompt(activity, executor, callback)
|
||||||
|
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(strings.biometricPromptTitle)
|
||||||
|
.setSubtitle(strings.biometricPromptSubtitle)
|
||||||
|
.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||||
|
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
prompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
@@ -77,13 +77,12 @@ class NdefHceService : HostApduService() {
|
|||||||
|
|
||||||
val ins = apdu[1]
|
val ins = apdu[1]
|
||||||
|
|
||||||
return when {
|
return when (ins) {
|
||||||
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
|
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
|
||||||
ins == 0xA4.toByte() -> handleSelect(apdu)
|
0xA4.toByte() -> handleSelect(apdu)
|
||||||
|
|
||||||
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
|
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
|
||||||
ins == 0xB0.toByte() -> handleReadBinary(apdu)
|
0xB0.toByte() -> handleReadBinary(apdu)
|
||||||
|
|
||||||
else -> SW_UNKNOWN
|
else -> SW_UNKNOWN
|
||||||
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
|
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
|
|||||||
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
|
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawMessages != null && rawMessages.isNotEmpty()) {
|
if (!rawMessages.isNullOrEmpty()) {
|
||||||
val message = rawMessages[0] as NdefMessage
|
val message = rawMessages[0] as NdefMessage
|
||||||
val uri = extractUri(message)
|
val uri = extractUri(message)
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
|
|||||||
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
|
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
|
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
|
||||||
}
|
}
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CalendarToday
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
private class AddConnectionState {
|
||||||
|
var description by mutableStateOf("")
|
||||||
|
var selectedPermissions by mutableStateOf(
|
||||||
|
ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
|
||||||
|
)
|
||||||
|
var neverExpires by mutableStateOf(false)
|
||||||
|
var expiry: LocalDateTime by mutableStateOf(
|
||||||
|
LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
|
||||||
|
)
|
||||||
|
var showDatePicker by mutableStateOf(false)
|
||||||
|
var showTimePicker by mutableStateOf(false)
|
||||||
|
var nextBudgetId by mutableIntStateOf(0)
|
||||||
|
var budgets by mutableStateOf(emptyList<BudgetDraft>())
|
||||||
|
|
||||||
|
// Converts the LocalDateTime expiry to a Unix epoch in seconds
|
||||||
|
fun expiryEpochSeconds(): Long =
|
||||||
|
expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NwcPermission.displayLabel(strings: AppStrings): String = when (serverKey) {
|
||||||
|
"pay" -> strings.nwc.permPay
|
||||||
|
"invoice" -> strings.nwc.permInvoice
|
||||||
|
"lookup" -> strings.nwc.permLookup
|
||||||
|
"history" -> strings.nwc.permHistory
|
||||||
|
"balance" -> strings.nwc.permBalance
|
||||||
|
"info" -> strings.nwc.permInfo
|
||||||
|
else -> serverKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun NwcAddConnectionSheet(
|
||||||
|
isCreating : Boolean,
|
||||||
|
onCreate : (
|
||||||
|
description : String,
|
||||||
|
permissions : List<String>,
|
||||||
|
expiresAt : Long,
|
||||||
|
budgets : List<NwcNewBudget>
|
||||||
|
) -> Unit,
|
||||||
|
onDismiss : () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val state = remember { AddConnectionState() }
|
||||||
|
|
||||||
|
|
||||||
|
if (state.showDatePicker) {
|
||||||
|
val initialMillis = state.expiry
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
val datePickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initialMillis
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { state.showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
val picked = java.time.Instant
|
||||||
|
.ofEpochMilli(millis)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
state.expiry = state.expiry
|
||||||
|
.withYear(picked.year)
|
||||||
|
.withMonth(picked.monthValue)
|
||||||
|
.withDayOfMonth(picked.dayOfMonth)
|
||||||
|
}
|
||||||
|
state.showDatePicker = false
|
||||||
|
}) { Text(strings.nwc.ok) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { state.showDatePicker = false }) { Text(strings.nwc.cancel) }
|
||||||
|
}
|
||||||
|
) { DatePicker(state = datePickerState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.showTimePicker) {
|
||||||
|
val timePickerState = rememberTimePickerState(
|
||||||
|
initialHour = state.expiry.hour,
|
||||||
|
initialMinute = state.expiry.minute,
|
||||||
|
is24Hour = true
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { state.showTimePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
state.expiry = state.expiry
|
||||||
|
.withHour(timePickerState.hour)
|
||||||
|
.withMinute(timePickerState.minute)
|
||||||
|
.withSecond(0)
|
||||||
|
.withNano(0)
|
||||||
|
state.showTimePicker = false
|
||||||
|
}) { Text(strings.nwc.ok) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { state.showTimePicker = false }) { Text(strings.nwc.cancel) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = timePickerState) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.newConnection,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Description ──────────────────────────────────────────────────
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.description,
|
||||||
|
onValueChange = { state.description = it },
|
||||||
|
label = { Text(strings.nwc.labelHint) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Expiry ───────────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = strings.nwc.expires, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (!state.neverExpires) {
|
||||||
|
val dateLabel = remember(state.expiry) {
|
||||||
|
DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).format(state.expiry)
|
||||||
|
}
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { state.showDatePicker = true },
|
||||||
|
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarToday,
|
||||||
|
contentDescription = strings.nwc.pickDate,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
val timeLabel = remember(state.expiry) {
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).format(state.expiry)
|
||||||
|
}
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { state.showTimePicker = true },
|
||||||
|
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = strings.nwc.pickTime,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Checkbox(
|
||||||
|
checked = state.neverExpires,
|
||||||
|
onCheckedChange = { state.neverExpires = it }
|
||||||
|
)
|
||||||
|
Text(text = strings.nwc.never, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.budgets,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.budgets += BudgetDraft(id = state.nextBudgetId)
|
||||||
|
state.nextBudgetId++
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = strings.nwc.addBudget,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(strings.nwc.addBudget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.budgets.forEach { draft ->
|
||||||
|
key(draft.id) {
|
||||||
|
BudgetRow(
|
||||||
|
draft = draft,
|
||||||
|
onChange = { updated ->
|
||||||
|
state.budgets = state.budgets.map { if (it.id == draft.id) updated else it }
|
||||||
|
},
|
||||||
|
onRemove = {
|
||||||
|
state.budgets = state.budgets.filter { it.id != draft.id }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permissions ──────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.permissions,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
ALL_PERMISSIONS.forEach { perm ->
|
||||||
|
key(perm.serverKey) {
|
||||||
|
val checked = perm.serverKey in state.selectedPermissions
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
state.selectedPermissions = if (on)
|
||||||
|
state.selectedPermissions + perm.serverKey
|
||||||
|
else
|
||||||
|
state.selectedPermissions - perm.serverKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(text = perm.displayLabel(strings), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val unnamed = strings.nwc.unnamed
|
||||||
|
// ── Create button ─────────────────────────────────────────────────
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val expiresAtSecs = if (state.neverExpires) 0L
|
||||||
|
else state.expiryEpochSeconds() // #10
|
||||||
|
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
|
||||||
|
onCreate(
|
||||||
|
state.description.trim().ifBlank { unnamed },
|
||||||
|
state.selectedPermissions.toList(),
|
||||||
|
expiresAtSecs,
|
||||||
|
validBudgets
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.selectedPermissions.isNotEmpty() && !isCreating,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isCreating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isCreating) strings.nwc.creating else strings.nwc.createConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BudgetRefreshWindow.displayLabel(strings: AppStrings): String = when (this) {
|
||||||
|
BudgetRefreshWindow.DAILY -> strings.nwc.refreshDaily
|
||||||
|
BudgetRefreshWindow.WEEKLY -> strings.nwc.refreshWeekly
|
||||||
|
BudgetRefreshWindow.MONTHLY -> strings.nwc.refreshMonthly
|
||||||
|
BudgetRefreshWindow.YEARLY -> strings.nwc.refreshYearly
|
||||||
|
BudgetRefreshWindow.NEVER -> strings.nwc.refreshNever
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BudgetRow(
|
||||||
|
draft : BudgetDraft,
|
||||||
|
onChange : (BudgetDraft) -> Unit,
|
||||||
|
onRemove : () -> Unit
|
||||||
|
) {
|
||||||
|
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
val fieldHeight = 64.dp
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.amountSats,
|
||||||
|
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
|
||||||
|
label = { Text(strings.nwc.sats) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f).height(fieldHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = dropdownExpanded,
|
||||||
|
onExpandedChange = { dropdownExpanded = it },
|
||||||
|
modifier = Modifier.weight(1f).height(fieldHeight)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.refreshWindow.displayLabel(strings),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(strings.nwc.resets) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(fieldHeight)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = dropdownExpanded,
|
||||||
|
onDismissRequest = { dropdownExpanded = false }
|
||||||
|
) {
|
||||||
|
BudgetRefreshWindow.entries.forEach { window ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(window.displayLabel(strings)) },
|
||||||
|
onClick = {
|
||||||
|
onChange(draft.copy(refreshWindow = window))
|
||||||
|
dropdownExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(fieldHeight)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = strings.nwc.removeBudget,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Cable
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isToday
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isYesterday
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
@Composable
|
||||||
|
internal fun NwcConnectionRow(
|
||||||
|
connection: NwcGetResponse,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
onDeleteConfirm: () -> Unit,
|
||||||
|
onConnectionClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val key = connection.data
|
||||||
|
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
// ── Swipe-to-delete state ────────────────────────────────────────────────
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
|
confirmValueChange = {
|
||||||
|
// Always reject — we never want the row to stay dismissed.
|
||||||
|
// The dialogue is triggered via LaunchedEffect on targetValue below.
|
||||||
|
false
|
||||||
|
},
|
||||||
|
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
|
||||||
|
)
|
||||||
|
|
||||||
|
// targetValue changes to EndToStart as soon as the drag crosses the
|
||||||
|
// threshold, even though confirmValueChange returns false and currentValue
|
||||||
|
// stays at Settled. Use a flag so we only fire once per swipe gesture.
|
||||||
|
var dialogTriggered by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Fires when the swipe crosses the threshold — trigger confirm dialogue
|
||||||
|
LaunchedEffect(dismissState.targetValue) {
|
||||||
|
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
|
||||||
|
&& !isDeleting
|
||||||
|
&& !dialogTriggered
|
||||||
|
) {
|
||||||
|
dialogTriggered = true
|
||||||
|
onDeleteConfirm()
|
||||||
|
// Reset in its own scope — won't be cancelled when targetValue changes back
|
||||||
|
scope.launch { dismissState.reset() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires when the row has fully snapped back — re-arm for next swipe
|
||||||
|
LaunchedEffect(dismissState.currentValue) {
|
||||||
|
if (dismissState.currentValue == SwipeToDismissBoxValue.Settled) {
|
||||||
|
dialogTriggered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
modifier = modifier,
|
||||||
|
enableDismissFromStartToEnd = false,
|
||||||
|
enableDismissFromEndToStart = !isDeleting,
|
||||||
|
backgroundContent = {
|
||||||
|
val fraction = dismissState.progress
|
||||||
|
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
|
||||||
|
.padding(end = 24.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = strings.nwc.deleteConnection,
|
||||||
|
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clickable(onClick = onConnectionClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Cable,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (expired)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = key.description.ifBlank { strings.nwc.unnamedConnection },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (expired)
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
val lastUsedText = when {
|
||||||
|
key.last_used <= 0L -> strings.nwc.neverUsed
|
||||||
|
isToday(key.last_used) -> strings.nwc.lastUsedToday
|
||||||
|
isYesterday(key.last_used) -> strings.nwc.lastUsedYesterday
|
||||||
|
else -> strings.nwc.lastUsedDate.format(formatEpochShort(key.last_used, strings.today, strings.yesterday))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (key.last_used <= 0L) strings.nwc.neverUsed
|
||||||
|
else lastUsedText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (expired) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
color = semantic.errorContainer,
|
||||||
|
contentColor = semantic.onErrorContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.expired,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Draw
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Link
|
||||||
|
import androidx.compose.material.icons.filled.LinkOff
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpoch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── ConnectionDetailScreen.kt ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Displays a Nostr Wallet Connect (NWC) connection and its associated budgets.
|
||||||
|
// Modelled on PaymentDetailScreen; reuses DetailSection / DetailRow / StatusChip
|
||||||
|
// from the same UI kit.
|
||||||
|
//
|
||||||
|
// bitcoin.design guidelines applied:
|
||||||
|
// • Hero card surfaces the most important identity info (wallet + description)
|
||||||
|
// with a clear active/expired status chip — users know at a glance whether
|
||||||
|
// the connection is live.
|
||||||
|
// • Permissions are shown as individual readable rows, not a raw comma string.
|
||||||
|
// • Budget progress uses a LinearProgressIndicator so spending headroom is
|
||||||
|
// immediately scannable without arithmetic.
|
||||||
|
// • Timestamps are human-readable; raw pubkeys are truncated + copyable.
|
||||||
|
// • Destructive "Revoke" action is placed at the bottom, separated from
|
||||||
|
// informational content, and uses errorContainer colours.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConnectionDetailScreen(
|
||||||
|
connection : ConnectionData, // data { pubkey, wallet, description, … }
|
||||||
|
budgets : List<BudgetData>, // budgets [ { id, pubkey, budget_msats, … } ]
|
||||||
|
onBack : () -> Unit,
|
||||||
|
onRevoke : (() -> Unit)? = null // optional – show Revoke button when provided
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(strings.nwc.connectionDetailTitle) }, // ← "Connection"
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.nwc.back // ← "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
ConnectionDetailContent(
|
||||||
|
connection = connection,
|
||||||
|
budgets = budgets,
|
||||||
|
onRevoke = onRevoke,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail content ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionDetailContent(
|
||||||
|
connection : ConnectionData,
|
||||||
|
budgets : List<BudgetData>,
|
||||||
|
onRevoke : (() -> Unit)?,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var showRevokeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val nowSeconds = remember { System.currentTimeMillis() / 1_000L }
|
||||||
|
val isExpired = connection.expiresAt > 0 && connection.expiresAt < nowSeconds
|
||||||
|
val permissions = connection.permissions
|
||||||
|
|
||||||
|
if (showRevokeDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRevokeDialog = false },
|
||||||
|
title = { Text(strings.nwc.revokeDialogTitle) },
|
||||||
|
text = { Text(strings.nwc.revokeDialogBody) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRevokeDialog = false
|
||||||
|
onRevoke?.invoke()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.revokeDialogConfirm,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRevokeDialog = false }) {
|
||||||
|
Text(strings.nwc.revokeDialogDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Hero card ─────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// App / wallet icon placeholder
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Link,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(14.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Wallet name
|
||||||
|
Text(
|
||||||
|
text = connection.description.ifBlank { strings.nwc.connectionUnknownWallet }, // ← "Unknown app"
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Active / Expired chip
|
||||||
|
ConnectionStatusChip(isExpired = isExpired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection details ────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.nwc.connectionSectionDetails) { // ← "Details"
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowCreated, // ← "Connected"
|
||||||
|
value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
|
||||||
|
value = if (connection.lastUsed > 0)
|
||||||
|
formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
|
||||||
|
else
|
||||||
|
strings.nwc.connectionNeverUsed // ← "Never"
|
||||||
|
)
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowExpires, // ← "Expires"
|
||||||
|
value = when {
|
||||||
|
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
|
||||||
|
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
|
||||||
|
formatEpoch(connection.expiresAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
else -> formatEpoch(
|
||||||
|
connection.expiresAt,
|
||||||
|
strings.today,
|
||||||
|
strings.yesterday
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pubkey – truncated display, full value copyable
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowPubkey, // ← "Public key"
|
||||||
|
value = "${connection.pubkey.take(8)}…${connection.pubkey.takeLast(8)}",
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("pubkey", connection.pubkey))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permissions ───────────────────────────────────────────────────────
|
||||||
|
if (permissions.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.nwc.connectionSectionPermissions) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
permissions.forEachIndexed { index, permission ->
|
||||||
|
PermissionRow(permission = permission, strings)
|
||||||
|
if (index < permissions.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budgets ───────────────────────────────────────────────────────────
|
||||||
|
if (budgets.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionSectionBudgets, // ← "Spending budgets"
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(budgets, key = { it.id }) { budget ->
|
||||||
|
BudgetCard(budget = budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revoke ────────────────────────────────────────────────────────────
|
||||||
|
if (onRevoke != null) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showRevokeDialog = true },
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LinkOff,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(strings.nwc.connectionRevoke) // ← "Revoke connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BudgetCard(budget: BudgetData) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
val totalSats = budget.budgetMsats / 1_000L
|
||||||
|
val usedSats = budget.usedBudgetMsats / 1_000L
|
||||||
|
val progress = if (budget.budgetMsats > 0)
|
||||||
|
(1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
|
||||||
|
else 0f
|
||||||
|
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Refresh window label e.g. "Daily budget"
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetTitle( // ← "Daily budget"
|
||||||
|
formatRefreshWindow(budget.refreshWindow)
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp)),
|
||||||
|
color = if (isOverBudget)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// Used / Total
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetUsed(usedSats), // ← "Used: 1,200 sats"
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isOverBudget)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetTotal(totalSats), // ← "of 10,000 sats"
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowCreated, // ← "Created"
|
||||||
|
value = formatEpoch(budget.createdAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = permission.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(7.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = permission.label(strings), // already localized
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status chip ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionStatusChip(isExpired: Boolean) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = if (isExpired)
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isExpired) strings.nwc.connectionStatusExpired // ← "Expired"
|
||||||
|
else strings.nwc.connectionStatusActive, // ← "Active"
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isExpired)
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a refresh_window value (seconds) to a human-readable label.
|
||||||
|
* Common NWC values: 86400 = daily, 604800 = weekly, 2592000 = monthly.
|
||||||
|
*/
|
||||||
|
private fun formatRefreshWindow(seconds: Long): String = when (seconds) {
|
||||||
|
0L -> "One-time"
|
||||||
|
86_400L -> "Daily"
|
||||||
|
604_800L -> "Weekly"
|
||||||
|
2_592_000L -> "Monthly"
|
||||||
|
else -> "${seconds / 86_400} days"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw NWC method string to a friendly label.
|
||||||
|
* Extend as your permission set grows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatPermissionLabel(permission: String, strings: AppStrings): String =
|
||||||
|
when (permission.trim().lowercase()) {
|
||||||
|
"pay_invoice" -> strings.nwc.permPayInvoice // ← "Pay invoices"
|
||||||
|
"get_balance" -> strings.nwc.permGetBalance // ← "Read balance"
|
||||||
|
"get_info" -> strings.nwc.permGetInfo // ← "Read wallet info"
|
||||||
|
"make_invoice" -> strings.nwc.permMakeInvoice // ← "Create invoices"
|
||||||
|
"lookup_invoice" -> strings.nwc.permLookupInvoice // ← "Look up invoices"
|
||||||
|
"list_transactions" -> strings.nwc.permListTransactions // ← "View transaction history"
|
||||||
|
"sign_message" -> strings.nwc.permSignMessage // ← "Sign messages"
|
||||||
|
else -> permission // fallback: raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a permission string to a representative icon. */
|
||||||
|
private fun permissionIcon(permission: String): ImageVector =
|
||||||
|
when (permission.trim().lowercase()) {
|
||||||
|
"pay_invoice" -> Icons.Default.ArrowUpward
|
||||||
|
"get_balance" -> Icons.Default.AccountBalanceWallet
|
||||||
|
"get_info" -> Icons.Default.Info
|
||||||
|
"make_invoice" -> Icons.Default.ArrowDownward
|
||||||
|
"lookup_invoice" -> Icons.Default.Search
|
||||||
|
"list_transactions" -> Icons.AutoMirrored.Filled.List
|
||||||
|
"sign_message" -> Icons.Default.Draw
|
||||||
|
else -> Icons.Default.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data models (if not already defined elsewhere) ───────────────────────────
|
||||||
|
|
||||||
|
data class ConnectionData(
|
||||||
|
val pubkey : String,
|
||||||
|
val wallet : String,
|
||||||
|
val description : String,
|
||||||
|
val expiresAt : Long, // Unix seconds; 0 = no expiry
|
||||||
|
val permissions : List<ConnectionPermission>,
|
||||||
|
val createdAt : Long, // Unix seconds
|
||||||
|
val lastUsed : Long // Unix seconds; 0 = never
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved permission — produced by matching NwcKey.permissions against
|
||||||
|
* ALL_PERMISSIONS and carrying only what the detail screen needs.
|
||||||
|
*/
|
||||||
|
data class ConnectionPermission(
|
||||||
|
val serverKey : String, // "pay", "invoice", "lookup", etc.
|
||||||
|
val icon : ImageVector
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ConnectionPermission.label(strings: AppStrings): String =
|
||||||
|
when (serverKey) {
|
||||||
|
"pay" -> strings.nwc.permSendPayments // "Send payments"
|
||||||
|
"invoice" -> strings.nwc.permCreateInvoices // "Create invoices"
|
||||||
|
"lookup" -> strings.nwc.permLookupInvoiceStatus // "Lookup status of invoice"
|
||||||
|
"history" -> strings.nwc.permReadTransactionHistory // "Read transaction history"
|
||||||
|
"balance" -> strings.nwc.permReadWalletBalance // "Read wallet balance"
|
||||||
|
"info" -> strings.nwc.permReadAccountInfo // "Read account info"
|
||||||
|
else -> serverKey
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetData(
|
||||||
|
val id : Long,
|
||||||
|
val pubkey : String,
|
||||||
|
val budgetMsats : Long,
|
||||||
|
val refreshWindow : Long, // seconds
|
||||||
|
val createdAt : Long,
|
||||||
|
val usedBudgetMsats : Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcKey
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the API model [NwcKey] to the UI domain model [ConnectionData].
|
||||||
|
* Keeps raw API types out of the Composable layer.
|
||||||
|
*/
|
||||||
|
internal fun NwcKey.toConnectionData() = ConnectionData(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expiresAt = expires_at,
|
||||||
|
permissions = parsePermissions(permissions),
|
||||||
|
createdAt = created_at,
|
||||||
|
lastUsed = last_used
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits the raw space-separated permissions string and matches each word
|
||||||
|
* against ALL_PERMISSIONS by serverKey, preserving ALL_PERMISSIONS order.
|
||||||
|
*
|
||||||
|
* "pay invoice lookup history balance info"
|
||||||
|
* → words = {"pay", "invoice", "lookup", "history", "balance", "info"}
|
||||||
|
* → filter ALL_PERMISSIONS where serverKey ∈ words
|
||||||
|
* → map to ConnectionPermission with localised label + icon
|
||||||
|
*/
|
||||||
|
private fun parsePermissions(raw: String): List<ConnectionPermission> {
|
||||||
|
val words = raw.trim().split("\\s+".toRegex()).toSet()
|
||||||
|
return ALL_PERMISSIONS
|
||||||
|
.filter { it.serverKey in words }
|
||||||
|
.map { perm ->
|
||||||
|
ConnectionPermission(
|
||||||
|
serverKey = perm.serverKey,
|
||||||
|
icon = permissionIcon(perm.serverKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionIcon(serverKey: String): ImageVector =
|
||||||
|
when (serverKey) {
|
||||||
|
"pay" -> Icons.Default.ArrowUpward
|
||||||
|
"invoice" -> Icons.Default.ArrowDownward
|
||||||
|
"lookup" -> Icons.Default.Search
|
||||||
|
"history" -> Icons.Default.List
|
||||||
|
"balance" -> Icons.Default.AccountBalanceWallet
|
||||||
|
"info" -> Icons.Default.Info
|
||||||
|
else -> Icons.Default.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the API model [NwcBudget] to the UI domain model [BudgetData].
|
||||||
|
* refresh_window is widened from Int to Long to match [BudgetData].
|
||||||
|
*/
|
||||||
|
internal fun NwcBudget.toBudgetData() = BudgetData(
|
||||||
|
id = id.toLong(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
budgetMsats = budget_msats,
|
||||||
|
refreshWindow = refresh_window.toLong(),
|
||||||
|
createdAt = created_at,
|
||||||
|
usedBudgetMsats = used_budget_msats
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||||
|
|
||||||
|
// ── Available permissions ────────────────────────────────────────────
|
||||||
|
internal data class NwcPermission(
|
||||||
|
val serverKey : String, // what we send in the permissions array
|
||||||
|
val default : Boolean // pre-checked by default
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val ALL_PERMISSIONS = listOf(
|
||||||
|
NwcPermission("pay", default = true),
|
||||||
|
NwcPermission("invoice", default = false),
|
||||||
|
NwcPermission("lookup", default = false),
|
||||||
|
NwcPermission("history", default = false),
|
||||||
|
NwcPermission("balance", default = false),
|
||||||
|
NwcPermission("info", default = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Budget ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient UI state for a single budget row in the "Add connection" sheet.
|
||||||
|
* Converted to [NwcNewBudget] on submit.
|
||||||
|
*/
|
||||||
|
enum class BudgetRefreshWindow(val seconds: Int) {
|
||||||
|
DAILY (86_400),
|
||||||
|
WEEKLY (604_800),
|
||||||
|
MONTHLY (2_592_000),
|
||||||
|
YEARLY (31_536_000),
|
||||||
|
NEVER (0);
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetDraft(
|
||||||
|
val id : Int = 0, // stable key for LazyColumn / forEach
|
||||||
|
val amountSats : String = "", // raw text field value (sats)
|
||||||
|
val refreshWindow : BudgetRefreshWindow = BudgetRefreshWindow.MONTHLY
|
||||||
|
) {
|
||||||
|
/** Returns null when the amount field is blank or non-numeric. */
|
||||||
|
fun toNwcNewBudget(): NwcNewBudget? {
|
||||||
|
val sats = amountSats.trim().toLongOrNull()?.takeIf { it > 0 } ?: return null
|
||||||
|
return NwcNewBudget(
|
||||||
|
budget_msats = sats * 1_000L,
|
||||||
|
refresh_window = refreshWindow.seconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcPairingUrlDialog(
|
||||||
|
url : String,
|
||||||
|
onDismiss : () -> Unit,
|
||||||
|
onNoAppFound : () -> Unit
|
||||||
|
) {
|
||||||
|
// Brightness state lives here so the dialog owns the screen-brightness side-effect
|
||||||
|
val brightness = rememberBrightnessController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(strings.nwc.connectionCreated) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.scanOrCopyHint,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
val strings = strings
|
||||||
|
QrDisplayCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
content = url,
|
||||||
|
contentDescription = strings.nwc.nwcPairingQrCode,
|
||||||
|
clipLabel = strings.nwc.nwcConnectionString,
|
||||||
|
shareTitle = strings.nwc.shareNwcConnectionString,
|
||||||
|
textToCopy = url,
|
||||||
|
brightnessOn = brightness.isOn,
|
||||||
|
onToggleBrightness = brightness.toggle,
|
||||||
|
isNfcEmulating = null,
|
||||||
|
qrModifier = Modifier.fillMaxWidth(),
|
||||||
|
onOpenUri = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
|
try {
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(intent, strings.nwc.openInApp)
|
||||||
|
)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
onNoAppFound() // #17: no Toast — caller handles it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {}, // actions live inside QrDisplayCard
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.nwc.done) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NwcScreen(
|
||||||
|
viewModel : NwcViewModel,
|
||||||
|
onBack : () -> Unit,
|
||||||
|
onConnectionClick : (pubkey: String) -> Unit
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
|
||||||
|
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
|
||||||
|
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
|
||||||
|
val showAddSheet by viewModel.showAddSheet.collectAsStateWithLifecycle()
|
||||||
|
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
val refresh = { viewModel.loadConnections(includeExpired = true, strings) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadConnections(includeExpired = true, strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(pairingUrl) {
|
||||||
|
if (pairingUrl != null) {
|
||||||
|
viewModel.closeAddSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(strings.nwc.walletConnect) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.nwc.back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = strings.nwc.addConnection)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { innerPadding ->
|
||||||
|
|
||||||
|
val isRefreshing = uiState is NwcUiState.Loading ||
|
||||||
|
(uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = refresh,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is NwcUiState.Loading -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NwcUiState.Error -> {
|
||||||
|
NwcErrorContent(
|
||||||
|
message = state.message,
|
||||||
|
onRetry = refresh,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NwcUiState.Success -> {
|
||||||
|
if (state.connections.isEmpty()) {
|
||||||
|
NwcEmptyContent(
|
||||||
|
onAddNew = { viewModel.openAddSheet() },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.connections,
|
||||||
|
key = { it.data.pubkey }
|
||||||
|
) { connection ->
|
||||||
|
NwcConnectionRow(
|
||||||
|
connection,
|
||||||
|
isDeleting,
|
||||||
|
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
|
||||||
|
{ onConnectionClick(connection.data.pubkey) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add connection sheet ───────────────────────────────────────────────────
|
||||||
|
if (showAddSheet) {
|
||||||
|
NwcAddConnectionSheet(
|
||||||
|
isCreating = isCreating,
|
||||||
|
onCreate = { description, permissions, expiresAt, budgets ->
|
||||||
|
viewModel.createConnection(
|
||||||
|
description = description,
|
||||||
|
permissions = permissions,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
budgets = budgets,
|
||||||
|
strings = strings
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeAddSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pairing URL dialog ─────────────────────────────────────────────────────
|
||||||
|
pairingUrl?.let { url ->
|
||||||
|
val strings = strings
|
||||||
|
NwcPairingUrlDialog(
|
||||||
|
url = url,
|
||||||
|
onDismiss = { viewModel.clearPairingUrl() },
|
||||||
|
onNoAppFound = {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(strings.nwc.noCompatibleAppFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog ─────────────────────────────────────────────
|
||||||
|
pendingDeletePubkey?.let {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
|
title = { Text(strings.nwc.removeConnection) },
|
||||||
|
text = { Text(strings.nwc.removeConnectionDetail) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.confirmDelete(strings) }
|
||||||
|
) { Text(strings.nwc.remove, color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.cancelDelete() }) { Text(strings.nwc.cancel) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
|
||||||
|
// ── Empty state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcEmptyContent(
|
||||||
|
onAddNew : () -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(strings.nwc.noConnectionsYet, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
strings.nwc.tapToAddHint,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Button(onClick = onAddNew) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(strings.nwc.addConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcErrorContent(
|
||||||
|
message : String,
|
||||||
|
onRetry : () -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
strings.nwc.somethingWentWrong,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = onRetry) { Text(strings.nwc.retry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user