Compare commits
62 Commits
0.1.0
...
0aa55710a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,26 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "com.bitcointxoko.gudariwallet",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "ONE_OF_MANY",
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"filterType": "ABI",
|
||||||
|
"value": "arm64-v8a"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 1,
|
||||||
|
"versionName": "0.1.0",
|
||||||
|
"outputFile": "app-arm64-v8a-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File",
|
||||||
|
"minSdkVersionForDexing": 26
|
||||||
|
}
|
||||||
@@ -10,16 +10,23 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.NFC" />
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<!-- Allows resolveActivity() to see apps that handle the NWC URI scheme -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="nostr+walletconnect" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".GudariWalletApplication"
|
android:name=".GudariWalletApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class GudariWalletApplication : Application(), Configuration.Provider {
|
|||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
get() {
|
get() {
|
||||||
val secrets = EncryptedSecretStore(this)
|
val secrets = EncryptedSecretStore(this)
|
||||||
val api = LNbitsClient.create(secrets)
|
val client = LNbitsClient(secrets)
|
||||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
|
val repo = LNbitsWalletRepository(api = client.api, secrets = secrets)
|
||||||
val paymentCache = PaymentCacheRepository(this)
|
val paymentCache = PaymentCacheRepository(this)
|
||||||
val syncStore = HistoricalSyncStore(this)
|
val syncStore = HistoricalSyncStore(this)
|
||||||
return Configuration.Builder()
|
return Configuration.Builder()
|
||||||
@@ -29,7 +29,7 @@ class GudariWalletApplication : Application(), Configuration.Provider {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
NotificationHelper.createChannels(this) // moved from MainActivity
|
NotificationHelper.createChannels(this)
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,18 @@ package com.bitcointxoko.gudariwallet
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.nfc.NfcAdapter
|
import android.nfc.NfcAdapter
|
||||||
import android.nfc.Tag
|
import android.nfc.Tag
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -37,12 +31,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import cafe.adriel.lyricist.ProvideStrings
|
import cafe.adriel.lyricist.ProvideStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.AppLockStore
|
||||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.lock.BiometricLockScreen
|
||||||
import com.bitcointxoko.gudariwallet.util.NotificationConstants
|
import com.bitcointxoko.gudariwallet.util.NotificationConstants
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private lateinit var secretStore: EncryptedSecretStore
|
private lateinit var secretStore: EncryptedSecretStore
|
||||||
|
private lateinit var appLockStore: AppLockStore
|
||||||
|
|
||||||
|
private val isUnlocked = mutableStateOf(false)
|
||||||
|
|
||||||
private val vm: WalletViewModel by viewModels {
|
private val vm: WalletViewModel by viewModels {
|
||||||
WalletViewModelFactory(application)
|
WalletViewModelFactory(application)
|
||||||
@@ -90,6 +89,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
secretStore = EncryptedSecretStore(applicationContext)
|
secretStore = EncryptedSecretStore(applicationContext)
|
||||||
|
appLockStore = AppLockStore(applicationContext)
|
||||||
val syncStore = HistoricalSyncStore(applicationContext)
|
val syncStore = HistoricalSyncStore(applicationContext)
|
||||||
val credentialsState = secretStore.credentials
|
val credentialsState = secretStore.credentials
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -97,6 +97,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
initialValue = null
|
initialValue = null
|
||||||
)
|
)
|
||||||
|
val appLockEnabledState = appLockStore.appLockEnabled
|
||||||
|
.stateIn(
|
||||||
|
scope = lifecycleScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = true // safe default: locked
|
||||||
|
)
|
||||||
|
|
||||||
// Must be first — before any notification is posted
|
// Must be first — before any notification is posted
|
||||||
// NotificationHelper.createChannels(applicationContext)
|
// NotificationHelper.createChannels(applicationContext)
|
||||||
@@ -127,34 +133,49 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
val credentials by credentialsState.collectAsState()
|
val credentials by credentialsState.collectAsState()
|
||||||
|
val appLockEnabled by appLockEnabledState.collectAsState()
|
||||||
val isOnboarded = credentials?.isOnboarded == true
|
val isOnboarded = credentials?.isOnboarded == true
|
||||||
|
|
||||||
if (isOnboarded) {
|
when {
|
||||||
LaunchedEffect(Unit) {
|
!isOnboarded -> {
|
||||||
WalletNotificationService.start(this@MainActivity)
|
OnboardingScreen(
|
||||||
requestNotificationPermissionIfNeeded()
|
secretStore = secretStore,
|
||||||
|
syncStore = syncStore,
|
||||||
|
appLockStore = appLockStore,
|
||||||
|
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
||||||
|
onComplete = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
appLockEnabled && !isUnlocked.value -> {
|
||||||
|
BiometricLockScreen(
|
||||||
|
onUnlocked = { isUnlocked.value = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
WalletNotificationService.start(this@MainActivity)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
}
|
||||||
|
WalletScreen(
|
||||||
|
vm = vm,
|
||||||
|
nfcVm = nfcVm,
|
||||||
|
pendingPaymentHash = pendingPaymentHash,
|
||||||
|
pendingNavigateToHistory = pendingNavigateToHistory,
|
||||||
|
pendingPaymentUri = pendingPaymentUri,
|
||||||
|
lyricist = lyricist
|
||||||
|
)
|
||||||
}
|
}
|
||||||
WalletScreen(
|
|
||||||
vm = vm,
|
|
||||||
nfcVm = nfcVm,
|
|
||||||
pendingPaymentHash = pendingPaymentHash,
|
|
||||||
pendingNavigateToHistory = pendingNavigateToHistory,
|
|
||||||
pendingPaymentUri = pendingPaymentUri,
|
|
||||||
lyricist = lyricist
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
OnboardingScreen(
|
|
||||||
secretStore = secretStore,
|
|
||||||
syncStore = syncStore,
|
|
||||||
onRequestNotificationPermission = { requestNotificationPermissionIfNeeded() },
|
|
||||||
onComplete = {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
isUnlocked.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// ── NFC foreground dispatch ───────────────────────────────────────────────
|
// ── NFC foreground dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import com.google.gson.TypeAdapter
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import com.google.gson.stream.JsonReader
|
import kotlinx.serialization.KSerializer
|
||||||
import com.google.gson.stream.JsonToken
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import com.google.gson.stream.JsonWriter
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LNbits returns `comment` in PaymentExtra as either:
|
* LNbits returns `comment` in PaymentExtra as either:
|
||||||
@@ -11,31 +18,29 @@ import com.google.gson.stream.JsonWriter
|
|||||||
* - a JSON array ["Thanks!"]
|
* - a JSON array ["Thanks!"]
|
||||||
* - null
|
* - null
|
||||||
*
|
*
|
||||||
* Registered centrally in [GsonProvider] — but also applied via @JsonAdapter
|
* Mirrors the behavior of the old Gson FlexibleStringAdapter.
|
||||||
* on the field for extra safety.
|
|
||||||
*/
|
*/
|
||||||
class FlexibleStringAdapter : TypeAdapter<String?>() {
|
object FlexibleStringSerializer : KSerializer<String?> {
|
||||||
|
|
||||||
override fun write(out: JsonWriter, value: String?) {
|
override val descriptor: SerialDescriptor =
|
||||||
if (value == null) out.nullValue() else out.value(value)
|
buildClassSerialDescriptor("FlexibleString")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
override fun serialize(encoder: Encoder, value: String?) {
|
||||||
|
if (value == null) encoder.encodeNull()
|
||||||
|
else encoder.encodeString(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun read(reader: JsonReader): String? {
|
override fun deserialize(decoder: Decoder): String? {
|
||||||
return when (reader.peek()) {
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
JsonToken.NULL -> { reader.nextNull(); null }
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
JsonToken.BEGIN_ARRAY -> {
|
is JsonNull -> null
|
||||||
val parts = mutableListOf<String>()
|
is JsonPrimitive -> element.content // plain string → as-is
|
||||||
reader.beginArray()
|
is JsonArray -> element // array → join elements
|
||||||
while (reader.hasNext()) {
|
.mapNotNull { if (it is JsonNull) null else it.jsonPrimitive.content }
|
||||||
when (reader.peek()) {
|
.joinToString(", ")
|
||||||
JsonToken.NULL -> reader.nextNull()
|
.ifEmpty { null }
|
||||||
else -> parts.add(reader.nextString())
|
else -> null
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.endArray()
|
|
||||||
parts.joinToString(", ").ifEmpty { null }
|
|
||||||
}
|
|
||||||
else -> reader.nextString()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the app's Gson instance.
|
|
||||||
*
|
|
||||||
* All serialization/deserialization — Retrofit, SharedPreferences caches,
|
|
||||||
* and WebSocket message parsing — must use this instance so that registered
|
|
||||||
* type adapters (e.g. [SuccessActionAdapter]) are applied consistently
|
|
||||||
* everywhere.
|
|
||||||
*
|
|
||||||
* To add a new adapter: register it here once. It will automatically apply
|
|
||||||
* to every deserialization path in the app.
|
|
||||||
*/
|
|
||||||
object GsonProvider {
|
|
||||||
val gson: Gson = GsonBuilder()
|
|
||||||
.registerTypeAdapter(SuccessAction::class.java, SuccessActionAdapter())
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the app's JSON instance.
|
||||||
|
*
|
||||||
|
* All serialization/deserialization — Retrofit, DataStore caches,
|
||||||
|
* and WebSocket message parsing — must use this instance so that
|
||||||
|
* configuration is applied consistently everywhere.
|
||||||
|
*
|
||||||
|
* Custom type serializers (e.g. [SuccessActionSerializer],
|
||||||
|
* [FlexibleStringSerializer]) are registered directly on the model
|
||||||
|
* classes via @Serializable(with = ...) so no explicit registration
|
||||||
|
* is needed here.
|
||||||
|
*/
|
||||||
|
object JsonProvider {
|
||||||
|
val json: Json = Json {
|
||||||
|
ignoreUnknownKeys = true // tolerate extra fields from the API
|
||||||
|
isLenient = true // accept unquoted/malformed JSON from some endpoints
|
||||||
|
explicitNulls = false // omit null fields when serializing requests
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
@@ -13,7 +15,7 @@ interface LNbitsApi {
|
|||||||
/** GET /api/v1/wallet — returns balance and wallet info */
|
/** GET /api/v1/wallet — returns balance and wallet info */
|
||||||
@GET("api/v1/wallet")
|
@GET("api/v1/wallet")
|
||||||
suspend fun getWallet(
|
suspend fun getWallet(
|
||||||
@Header("X-Api-Key") apiKey: String
|
@Header("X-Api-Key") invoiceKey: String
|
||||||
): WalletResponse
|
): WalletResponse
|
||||||
|
|
||||||
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
||||||
@@ -42,10 +44,10 @@ interface LNbitsApi {
|
|||||||
): PayInvoiceResponse
|
): PayInvoiceResponse
|
||||||
|
|
||||||
/** GET /api/v1/payments — returns payment history */
|
/** GET /api/v1/payments — returns payment history */
|
||||||
@GET("api/v1/payments")
|
// @GET("api/v1/payments")
|
||||||
suspend fun getPayments(
|
// suspend fun getPayments(
|
||||||
@Header("X-Api-Key") apiKey: String
|
// @Header("X-Api-Key") apiKey: String
|
||||||
): List<WsPayment>
|
// ): List<WsPayment>
|
||||||
|
|
||||||
/** GET /api/v1/payments/paginated — returns bulk payment history */
|
/** GET /api/v1/payments/paginated — returns bulk payment history */
|
||||||
@GET("api/v1/payments/paginated")
|
@GET("api/v1/payments/paginated")
|
||||||
@@ -144,4 +146,61 @@ interface LNbitsApi {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/currencies")
|
@GET("api/v1/currencies")
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
|
||||||
|
// ── NWC Provider ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/permissions")
|
||||||
|
suspend fun getNwcPermissions(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/nwc")
|
||||||
|
suspend fun getNwcKeys(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Query("include_expired") includeExpired: Boolean = false
|
||||||
|
): List<NwcGetResponse>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun getNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String,
|
||||||
|
@Query("include_expired") includeExpired: Boolean = false,
|
||||||
|
@Query("refresh_last_used") refreshLastUsed: Boolean = false
|
||||||
|
): NwcGetResponse
|
||||||
|
|
||||||
|
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun registerNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String,
|
||||||
|
@Body request: NwcRegistrationRequest
|
||||||
|
): NwcGetResponse
|
||||||
|
|
||||||
|
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
|
||||||
|
suspend fun deleteNwcKey(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("pubkey") pubkey: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/pairing/{secret}")
|
||||||
|
suspend fun getNwcPairingUrl(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Path("secret") secret: String
|
||||||
|
): String
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/config")
|
||||||
|
suspend fun getNwcConfig(
|
||||||
|
@Header("X-Api-Key") adminKey: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@GET("nwcprovider/api/v1/config/{key}")
|
||||||
|
suspend fun getNwcConfigValue(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Path("key") key: String
|
||||||
|
): Map<String, Any>
|
||||||
|
|
||||||
|
@POST("nwcprovider/api/v1/config")
|
||||||
|
suspend fun setNwcConfig(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Body config: Map<String, Any>
|
||||||
|
): Map<String, Any>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,88 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.BuildConfig
|
|
||||||
import com.bitcointxoko.gudariwallet.security.SecretStore
|
import com.bitcointxoko.gudariwallet.security.SecretStore
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
object LNbitsClient {
|
class LNbitsClient(secrets: SecretStore) {
|
||||||
|
|
||||||
// Single shared client — used by both Retrofit and WebSocket
|
// Scope lives as long as this client — SupervisorJob so one failure
|
||||||
val httpClient: OkHttpClient = OkHttpClient.Builder()
|
// doesn't cancel the URL-watching coroutine.
|
||||||
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
|
// Cached in memory. Starts blank; populated as soon as DataStore emits.
|
||||||
.apply {
|
private val cachedBaseUrl = AtomicReference("")
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(HttpLoggingInterceptor().apply {
|
init {
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
scope.launch {
|
||||||
})
|
// Suspend until DataStore has emitted at least once.
|
||||||
|
// This primes cachedBaseUrl before any request can realistically fire.
|
||||||
|
val initial = secrets.credentials.first()
|
||||||
|
cachedBaseUrl.set(initial.baseUrl)
|
||||||
|
// Then keep it updated reactively for the lifetime of this client.
|
||||||
|
secrets.credentials.collect { credentials ->
|
||||||
|
cachedBaseUrl.set(credentials.baseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.build()
|
}
|
||||||
|
|
||||||
fun create(secrets: SecretStore): LNbitsApi {
|
val api: LNbitsApi by lazy { buildApi() }
|
||||||
// Auth header interceptor — reads invoice key per-request (unchanged)
|
|
||||||
val authInterceptor = Interceptor { chain ->
|
|
||||||
val key = runBlocking { secrets.invoiceKey() }
|
|
||||||
chain.proceed(
|
|
||||||
chain.request().newBuilder()
|
|
||||||
.header("X-Api-Key", key)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic base URL interceptor — rewrites the placeholder host to the
|
private fun buildApi(): LNbitsApi {
|
||||||
// real base URL at request time, not at Retrofit construction time.
|
|
||||||
// This means credentials only need to be in DataStore by the time the
|
|
||||||
// first HTTP request fires, not at app startup.
|
|
||||||
val dynamicBaseUrlInterceptor = Interceptor { chain ->
|
val dynamicBaseUrlInterceptor = Interceptor { chain ->
|
||||||
val realBaseUrl = runBlocking { secrets.baseUrl() }
|
val originalRequest = chain.request()
|
||||||
.ifBlank { "https://placeholder.invalid" }
|
val originalUrl = originalRequest.url
|
||||||
.trimEnd('/')
|
|
||||||
val realHttpUrl = realBaseUrl.toHttpUrl()
|
if (originalUrl.host != "placeholder.invalid") {
|
||||||
|
return@Interceptor chain.proceed(originalRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val raw = cachedBaseUrl.get().ifBlank { null }
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"LNbits base URL is not configured. " +
|
||||||
|
"Complete onboarding before making API calls."
|
||||||
|
)
|
||||||
|
|
||||||
|
val realHttpUrl = raw.trimEnd('/').toHttpUrl()
|
||||||
|
|
||||||
val originalUrl = chain.request().url
|
|
||||||
val newUrl = originalUrl.newBuilder()
|
val newUrl = originalUrl.newBuilder()
|
||||||
.scheme(realHttpUrl.scheme)
|
.scheme(realHttpUrl.scheme)
|
||||||
.host(realHttpUrl.host)
|
.host(realHttpUrl.host)
|
||||||
.port(realHttpUrl.port)
|
.port(realHttpUrl.port)
|
||||||
|
// Preserve sub-path prefix (e.g. /lnbits in reverse-proxy setups)
|
||||||
|
.encodedPath(
|
||||||
|
realHttpUrl.encodedPath.trimEnd('/') + originalUrl.encodedPath
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
chain.proceed(
|
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
chain.request().newBuilder()
|
|
||||||
.url(newUrl)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = httpClient.newBuilder()
|
val client = LNbitsHttpClient.instance.newBuilder()
|
||||||
.addInterceptor(authInterceptor)
|
|
||||||
.addInterceptor(dynamicBaseUrlInterceptor)
|
.addInterceptor(dynamicBaseUrlInterceptor)
|
||||||
.apply {
|
|
||||||
// Logging goes LAST — after URL rewrite — so logs show the real URL
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addInterceptor(HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("https://placeholder.invalid/") // structurally required by Retrofit;
|
.baseUrl("https://placeholder.invalid/")
|
||||||
// always overwritten by the interceptor
|
// ↑ Structurally required: Retrofit validates that baseUrl is a
|
||||||
|
// well-formed absolute URL at construction time. The interceptor
|
||||||
|
// rewrites it before any real request leaves the device.
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
|
.addConverterFactory(
|
||||||
|
JsonProvider.json.asConverterFactory(
|
||||||
|
"application/json; charset=UTF-8".toMediaType()
|
||||||
|
)
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
.create(LNbitsApi::class.java)
|
.create(LNbitsApi::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
// internal = visible within this Gradle module only.
|
||||||
|
// Intentionally not private: WalletNotificationService accesses this
|
||||||
|
// directly because Android instantiates Services via reflection —
|
||||||
|
// constructor injection is not possible. All other consumers must use
|
||||||
|
// LNbitsClient.api instead.
|
||||||
|
object LNbitsHttpClient {
|
||||||
|
internal val instance: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -1,133 +1,147 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.google.gson.TypeAdapter
|
import kotlinx.serialization.KSerializer
|
||||||
import com.google.gson.annotations.JsonAdapter
|
import kotlinx.serialization.SerialName
|
||||||
import com.google.gson.annotations.SerializedName
|
import kotlinx.serialization.Serializable
|
||||||
import com.google.gson.stream.JsonReader
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
import com.google.gson.stream.JsonToken
|
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||||
import com.google.gson.stream.JsonWriter
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonEncoder
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
// ── Wallet ────────────────────────────────────────────────────────────────────
|
// ── Wallet ────────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class WalletResponse(
|
data class WalletResponse(
|
||||||
val id: String,
|
val id: String = "",
|
||||||
val name: String,
|
val name: String = "",
|
||||||
val balance: Long // millisatoshis
|
val balance: Long = 0L // millisatoshis
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Create invoice ────────────────────────────────────────────────────────────
|
// ── Create invoice ────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class CreateInvoiceRequest(
|
data class CreateInvoiceRequest(
|
||||||
val out: Boolean,
|
val out: Boolean,
|
||||||
val amount: Long, // satoshis
|
val amount: Long, // satoshis
|
||||||
val memo: String
|
val memo: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class CreateInvoiceResponse(
|
data class CreateInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("payment_request") val paymentRequest: String,
|
@SerialName("payment_request") val paymentRequest: String = "",
|
||||||
@SerializedName("expiry") val expiry : String?
|
@SerialName("expiry") val expiry: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PayInvoiceRequest(
|
data class PayInvoiceRequest(
|
||||||
val out: Boolean,
|
val out: Boolean,
|
||||||
val bolt11: String
|
val bolt11: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayInvoiceResponse(
|
data class PayInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String
|
@SerialName("checking_id") val checkingId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Check payment ─────────────────────────────────────────────────────────────
|
// ── Check payment ─────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PaymentStatusResponse(
|
data class PaymentStatusResponse(
|
||||||
val paid: Boolean,
|
val paid: Boolean = false,
|
||||||
val details: PaymentDetails? = null
|
val details: PaymentDetails? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PaymentDetails(
|
data class PaymentDetails(
|
||||||
val amount: Long?,
|
val amount: Long? = null,
|
||||||
val fee: Long?,
|
val fee: Long? = null,
|
||||||
val memo: String?
|
val memo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class DecodeInvoiceRequest(
|
data class DecodeInvoiceRequest(
|
||||||
val data: String
|
val data: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class DecodeInvoiceResponse(
|
data class DecodeInvoiceResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash : String? = null,
|
@SerialName("payment_hash") val paymentHash: String? = null,
|
||||||
@SerializedName("amount_msat") val amountMsat : Long? = null,
|
@SerialName("amount_msat") val amountMsat: Long? = null,
|
||||||
@SerializedName("description") val description : String? = null,
|
@SerialName("description") val description: String? = null,
|
||||||
@SerializedName("payee") val payee : String? = null,
|
@SerialName("payee") val payee: String? = null,
|
||||||
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
|
@SerialName("route_hints") val routeHints: List<List<RouteHintHop>>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class RouteHintHop(
|
data class RouteHintHop(
|
||||||
@SerializedName("public_key") val publicKey : String,
|
@SerialName("public_key") val publicKey: String = "",
|
||||||
@SerializedName("short_channel_id") val shortChannelId : String? = null,
|
@SerialName("short_channel_id") val shortChannelId: String? = null,
|
||||||
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
|
@SerialName("base_fee") val baseFeeMsat: Long = 0,
|
||||||
@SerializedName("ppm_fee") val ppmFee : Long = 0,
|
@SerialName("ppm_fee") val ppmFee: Long = 0,
|
||||||
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
|
@SerialName("cltv_expiry_delta") val cltvExpiryDelta: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlScanResponse(
|
data class LnurlScanResponse(
|
||||||
val tag: String?,
|
val tag: String? = null,
|
||||||
// ── payRequest fields ────────────────────────────────────────────────────
|
// ── payRequest fields ─────────────────────────────────────────────────────
|
||||||
val callback: String? = null,
|
val callback: String? = null,
|
||||||
@SerializedName("minSendable") val minSendable: Long? = null,
|
@SerialName("minSendable") val minSendable: Long? = null,
|
||||||
@SerializedName("maxSendable") val maxSendable: Long? = null,
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
||||||
val metadata: String? = null,
|
val metadata: String? = null,
|
||||||
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
||||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null,
|
||||||
val bolt11: String? = null,
|
val bolt11: String? = null,
|
||||||
@SerializedName("amount_msat") val amountMsat: Long? = null,
|
@SerialName("amount_msat") val amountMsat: Long? = null,
|
||||||
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
||||||
// ── withdrawRequest fields ───────────────────────────────────────────────
|
// ── withdrawRequest fields ────────────────────────────────────────────────
|
||||||
val k1 : String? = null,
|
val k1: String? = null,
|
||||||
@SerializedName("defaultDescription")
|
@SerialName("defaultDescription") val defaultDescription: String? = null,
|
||||||
val defaultDescription: String? = null,
|
@SerialName("minWithdrawable") val minWithdrawable: Long? = null,
|
||||||
@SerializedName("minWithdrawable")
|
@SerialName("maxWithdrawable") val maxWithdrawable: Long? = null,
|
||||||
val minWithdrawable : Long? = null,
|
@SerialName("balanceCheck") val balanceCheck: String? = null,
|
||||||
@SerializedName("maxWithdrawable")
|
@SerialName("currentBalance") val currentBalance: Long? = null,
|
||||||
val maxWithdrawable : Long? = null,
|
// ── Error fields ──────────────────────────────────────────────────────────
|
||||||
@SerializedName("balanceCheck")
|
val status: String? = null, // "OK" or "ERROR"
|
||||||
val balanceCheck : String? = null,
|
val reason: String? = null // error message when status == "ERROR"
|
||||||
@SerializedName("currentBalance")
|
|
||||||
val currentBalance : Long? = null,
|
|
||||||
// ── Error fields ─────────────────────────────────────────────────────────
|
|
||||||
val status : String? = null, // "OK" or "ERROR"
|
|
||||||
val reason : String? = null // error message when status == "ERROR"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlCallbackResponse(
|
data class LnurlCallbackResponse(
|
||||||
val pr : String, // BOLT-11 invoice to pay
|
val pr: String = "",
|
||||||
val routes : List<Any>? = null,
|
val routes: List<JsonElement>? = null,
|
||||||
@SerializedName("successAction")
|
@SerialName("successAction") val successAction: SuccessAction? = null,
|
||||||
val successAction : SuccessAction? = null,
|
val status: String? = null,
|
||||||
// Error fields (server may return status=ERROR instead of pr)
|
val reason: String? = null
|
||||||
val status : String? = null,
|
|
||||||
val reason : String? = null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlPayRes(
|
data class LnurlPayRes(
|
||||||
val tag: String?,
|
val tag: String? = null,
|
||||||
val callback: String?,
|
val callback: String? = null,
|
||||||
@SerializedName("minSendable") val minSendable: Long?,
|
@SerialName("minSendable") val minSendable: Long? = null,
|
||||||
@SerializedName("maxSendable") val maxSendable: Long?,
|
@SerialName("maxSendable") val maxSendable: Long? = null,
|
||||||
val metadata: String?,
|
val metadata: String? = null,
|
||||||
@SerializedName("commentAllowed") val commentAllowed: Int?,
|
@SerialName("commentAllowed") val commentAllowed: Int? = null,
|
||||||
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
@SerialName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
|
@SerialName("nostrPubkey") val nostrPubkey: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayLnurlRequest(
|
data class PayLnurlRequest(
|
||||||
val res: LnurlPayRes,
|
val res: LnurlPayRes,
|
||||||
val lnurl: String,
|
val lnurl: String,
|
||||||
@@ -135,45 +149,48 @@ data class PayLnurlRequest(
|
|||||||
val comment: String? = null
|
val comment: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PayLnurlResponse(
|
data class PayLnurlResponse(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String
|
@SerialName("checking_id") val checkingId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class WithdrawCallbackResponse(
|
data class WithdrawCallbackResponse(
|
||||||
val status : String,
|
val status: String = "",
|
||||||
val reason : String? = null
|
val reason: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class LnurlpLink(
|
data class LnurlpLink(
|
||||||
val id : String,
|
val id: String = "",
|
||||||
val wallet : String,
|
val wallet: String = "",
|
||||||
val username : String?, // the part before the @ in the lightning address
|
val username: String? = null,
|
||||||
val description : String?,
|
val description: String? = null,
|
||||||
val min : Long,
|
@Serializable(with = LongAsDoubleSerializer::class) val min: Long = 0L,
|
||||||
val max : Long
|
@Serializable(with = LongAsDoubleSerializer::class) val max: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// ── Payment history ───────────────────────────────────────────────────────────
|
// ── Payment history ───────────────────────────────────────────────────────────
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable
|
||||||
data class PaymentRecord(
|
data class PaymentRecord(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String,
|
@SerialName("checking_id") val checkingId: String = "",
|
||||||
@SerializedName("amount") val amountMsat: Long,
|
@SerialName("amount") val amountMsat: Long = 0L,
|
||||||
@SerializedName("fee") val feeMsat: Long,
|
@SerialName("fee") val feeMsat: Long = 0L,
|
||||||
@SerializedName("memo") val memo: String?,
|
@SerialName("memo") val memo: String? = null,
|
||||||
@SerializedName("time") val time: String,
|
@SerialName("time") val time: String = "",
|
||||||
@SerializedName("status") val status: String,
|
@SerialName("status") val status: String = "",
|
||||||
@SerializedName("bolt11") val bolt11: String?,
|
@SerialName("bolt11") val bolt11: String? = null,
|
||||||
@SerializedName("pending") val pending: Boolean,
|
@SerialName("pending") val pending: Boolean = false,
|
||||||
@SerializedName("preimage") val preimage: String?,
|
@SerialName("preimage") val preimage: String? = null,
|
||||||
@SerializedName("extra") val extra: PaymentExtra?,
|
@SerialName("extra") val extra: PaymentExtra? = null,
|
||||||
// Not from the API — populated only when loaded from Room via toDomain()
|
// Not from the API — populated only when loaded from Room via toDomain()
|
||||||
val createdAt: Long? = null,
|
val createdAt: Long? = null,
|
||||||
val pubkey: String? = null,
|
val pubkey: String? = null,
|
||||||
val alias: String? = null
|
val alias: String? = null
|
||||||
) {
|
) {
|
||||||
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
||||||
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
||||||
@@ -181,13 +198,13 @@ data class PaymentRecord(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromPayment(
|
fun fromPayment(
|
||||||
paymentHash : String,
|
paymentHash: String,
|
||||||
amountSats : Long,
|
amountSats: Long,
|
||||||
feeSats : Long?,
|
feeSats: Long?,
|
||||||
bolt11 : String?,
|
bolt11: String?,
|
||||||
memo : String?,
|
memo: String?,
|
||||||
pubkey : String?,
|
pubkey: String?,
|
||||||
alias : String?,
|
alias: String?,
|
||||||
): PaymentRecord = PaymentRecord(
|
): PaymentRecord = PaymentRecord(
|
||||||
paymentHash = paymentHash,
|
paymentHash = paymentHash,
|
||||||
checkingId = paymentHash,
|
checkingId = paymentHash,
|
||||||
@@ -207,23 +224,25 @@ data class PaymentRecord(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable
|
||||||
data class PaymentExtra(
|
data class PaymentExtra(
|
||||||
@SerializedName("tag") val tag: String?,
|
@SerialName("tag") val tag: String? = null,
|
||||||
@JsonAdapter(FlexibleStringAdapter::class)
|
@Serializable(with = FlexibleStringSerializer::class)
|
||||||
val comment: String?,
|
val comment: String? = null,
|
||||||
@SerializedName("success_action") val successAction: SuccessAction?,
|
@SerialName("success_action") @Serializable(with = SuccessActionSerializer::class)
|
||||||
@SerializedName("wallet_id") val walletId: String?,
|
val successAction: SuccessAction? = null,
|
||||||
@SerializedName("destination") val destination: String?,
|
@SerialName("wallet_id") val walletId: String? = null,
|
||||||
@SerializedName("expires_at") val expiresAt: String?
|
@SerialName("destination") val destination: String? = null,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
@Serializable(with = SuccessActionSerializer::class)
|
||||||
data class SuccessAction(
|
data class SuccessAction(
|
||||||
@SerializedName("tag") val tag: String?,
|
@SerialName("tag") val tag: String?,
|
||||||
@SerializedName("message") val message: String?,
|
@SerialName("message") val message: String?,
|
||||||
@SerializedName("url") val url: String?,
|
@SerialName("url") val url: String?,
|
||||||
@SerializedName("description") val description: String?
|
@SerialName("description") val description: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,93 +251,158 @@ data class SuccessAction(
|
|||||||
* - a plain string "Thanks!"
|
* - a plain string "Thanks!"
|
||||||
* - null
|
* - null
|
||||||
*
|
*
|
||||||
* Gson's default deserialiser throws IllegalStateException when it sees a
|
* kotlinx.serialization's default deserializer throws when it sees a
|
||||||
* string where it expects {. This adapter handles all three cases.
|
* string where it expects {. This serializer handles all three cases.
|
||||||
*
|
*
|
||||||
* Registered centrally in [GsonProvider] — applies to all deserialization
|
* Applied via @Serializable(with = SuccessActionSerializer::class) on
|
||||||
* paths automatically.
|
* the SuccessAction class and on PaymentExtra.successAction.
|
||||||
*/
|
*/
|
||||||
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
|
object SuccessActionSerializer : KSerializer<SuccessAction?> {
|
||||||
|
|
||||||
override fun write(out: JsonWriter, value: SuccessAction?) {
|
override val descriptor: SerialDescriptor =
|
||||||
if (value == null) { out.nullValue(); return }
|
buildClassSerialDescriptor("SuccessAction")
|
||||||
out.beginObject()
|
|
||||||
out.name("tag"); out.value(value.tag)
|
override fun serialize(encoder: Encoder, value: SuccessAction?) {
|
||||||
out.name("message"); out.value(value.message)
|
val jsonEncoder = encoder as JsonEncoder
|
||||||
out.name("url"); out.value(value.url)
|
if (value == null) {
|
||||||
out.name("description"); out.value(value.description)
|
jsonEncoder.encodeJsonElement(JsonNull)
|
||||||
out.endObject()
|
} else {
|
||||||
|
jsonEncoder.encodeJsonElement(
|
||||||
|
JsonObject(
|
||||||
|
mapOf(
|
||||||
|
"tag" to JsonPrimitive(value.tag),
|
||||||
|
"message" to JsonPrimitive(value.message),
|
||||||
|
"url" to JsonPrimitive(value.url),
|
||||||
|
"description" to JsonPrimitive(value.description)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun read(reader: JsonReader): SuccessAction? {
|
override fun deserialize(decoder: Decoder): SuccessAction? {
|
||||||
return when (reader.peek()) {
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
JsonToken.NULL -> { reader.nextNull(); null }
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
|
is JsonNull -> null
|
||||||
// Plain string — treat as a message-type success action
|
is JsonPrimitive -> {
|
||||||
JsonToken.STRING -> {
|
// Plain string — treat as a message-type success action
|
||||||
val text = reader.nextString()
|
val text = element.jsonPrimitive.content
|
||||||
if (text.isBlank()) null
|
if (text.isBlank()) null
|
||||||
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
||||||
}
|
}
|
||||||
|
is JsonObject -> {
|
||||||
// Normal object
|
SuccessAction(
|
||||||
JsonToken.BEGIN_OBJECT -> {
|
tag = element.jsonObject["tag"]?.jsonPrimitive?.content,
|
||||||
reader.beginObject()
|
message = element.jsonObject["message"]?.jsonPrimitive?.content,
|
||||||
var tag: String? = null
|
url = element.jsonObject["url"]?.jsonPrimitive?.content,
|
||||||
var message: String? = null
|
description = element.jsonObject["description"]?.jsonPrimitive?.content
|
||||||
var url: String? = null
|
)
|
||||||
var description: String? = null
|
|
||||||
while (reader.hasNext()) {
|
|
||||||
when (reader.nextName()) {
|
|
||||||
"tag" -> tag = reader.nextString()
|
|
||||||
"message" -> message = reader.nextString()
|
|
||||||
"url" -> url = reader.nextString()
|
|
||||||
"description" -> description = reader.nextString()
|
|
||||||
else -> reader.skipValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.endObject()
|
|
||||||
SuccessAction(tag = tag, message = message, url = url, description = description)
|
|
||||||
}
|
}
|
||||||
|
else -> null
|
||||||
else -> { reader.skipValue(); null }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class PaymentDetailResponse(
|
data class PaymentDetailResponse(
|
||||||
val paid: Boolean,
|
val paid: Boolean,
|
||||||
val details: PaymentRecord?
|
val details: PaymentRecord?
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── WebSocket payment message ─────────────────────────────────────────────────
|
// ── WebSocket payment message ─────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class WsPaymentMessage(
|
data class WsPaymentMessage(
|
||||||
@SerializedName("wallet_balance") val walletBalance: Long?,
|
@SerialName("wallet_balance") val walletBalance: Long? = null,
|
||||||
val payment: WsPayment?
|
val payment: WsPayment? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class WsPayment(
|
data class WsPayment(
|
||||||
@SerializedName("payment_hash") val paymentHash: String,
|
@SerialName("payment_hash") val paymentHash: String = "",
|
||||||
@SerializedName("checking_id") val checkingId: String,
|
@SerialName("checking_id") val checkingId: String = "",
|
||||||
@SerializedName("amount") val amount: Long,
|
@SerialName("amount") val amount: Long = 0L,
|
||||||
@SerializedName("fee") val fee: Long,
|
@SerialName("fee") val fee: Long = 0L,
|
||||||
@SerializedName("memo") val memo: String?,
|
@SerialName("memo") val memo: String? = null,
|
||||||
@SerializedName("time") val time: String,
|
@SerialName("time") val time: String = "",
|
||||||
@SerializedName("status") val status: String,
|
@SerialName("status") val status: String = "",
|
||||||
@SerializedName("bolt11") val bolt11: String?,
|
@SerialName("bolt11") val bolt11: String? = null,
|
||||||
@SerializedName("pending") val pending: Boolean,
|
@SerialName("pending") val pending: Boolean = false,
|
||||||
@SerializedName("preimage") val preimage: String?,
|
@SerialName("preimage") val preimage: String? = null,
|
||||||
@SerializedName("extra") val extra: PaymentExtra?
|
@SerialName("extra") val extra: PaymentExtra? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Fiat rate ─────────────────────────────────────────────────────────────────
|
||||||
// ── Fiat rate ────────────────────────────────────────────────────────────────
|
@Serializable
|
||||||
data class FiatRateResponse(
|
data class FiatRateResponse(
|
||||||
val rate: Double, // sat to fiat rate
|
val rate: Double = 0.0, // sat to fiat rate
|
||||||
val price : Double // BTC price in the requested fiat currency
|
val price: Double = 0.0 // BTC price in the requested fiat currency
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── History sync ─────────────────────────────────────────────────────────────
|
// ── History sync ──────────────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
data class PaginatedPaymentsResponse(
|
data class PaginatedPaymentsResponse(
|
||||||
@SerializedName("data") val data: List<PaymentRecord>,
|
@SerialName("data") val data: List<PaymentRecord> = emptyList(),
|
||||||
@SerializedName("total") val total: Int
|
@SerialName("total") val total: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── NWC Provider models ───────────────────────────────────────────────────────
|
||||||
|
@Serializable
|
||||||
|
data class NwcKey(
|
||||||
|
val pubkey: String = "",
|
||||||
|
val wallet: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val expires_at: Long = 0L,
|
||||||
|
val permissions: String = "",
|
||||||
|
val created_at: Long = 0L,
|
||||||
|
val last_used: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcBudget(
|
||||||
|
val id: Int = 0,
|
||||||
|
val pubkey: String = "",
|
||||||
|
val budget_msats: Long = 0L,
|
||||||
|
val refresh_window: Int = 0,
|
||||||
|
val created_at: Long = 0L,
|
||||||
|
val used_budget_msats: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcGetResponse(
|
||||||
|
val data: NwcKey = NwcKey(),
|
||||||
|
val budgets: List<NwcBudget> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request models — no defaults needed (you control the fields, never deserializing)
|
||||||
|
@Serializable
|
||||||
|
data class NwcNewBudget(
|
||||||
|
val budget_msats: Long,
|
||||||
|
val refresh_window: Int,
|
||||||
|
val created_at: Long = System.currentTimeMillis() / 1000L
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NwcRegistrationRequest(
|
||||||
|
val permissions: List<String>,
|
||||||
|
val description: String,
|
||||||
|
val expires_at: Long,
|
||||||
|
val budgets: List<NwcNewBudget> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes a JSON number that may arrive as either an integer (1)
|
||||||
|
* or a float (1.0) into a Long, truncating any fractional part.
|
||||||
|
*/
|
||||||
|
object LongAsDoubleSerializer : KSerializer<Long> {
|
||||||
|
override val descriptor = buildClassSerialDescriptor("LongAsDouble")
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value)
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): Long {
|
||||||
|
val jsonDecoder = decoder as JsonDecoder
|
||||||
|
return when (val element = jsonDecoder.decodeJsonElement()) {
|
||||||
|
is JsonPrimitive -> element.content.toDouble().toLong()
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
// ── mempool.space ─────────────────────────────────────────────────────────────
|
// ── mempool.space ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class MempoolNodeResponse(
|
data class MempoolNodeResponse(
|
||||||
val alias: String?,
|
val alias: String?,
|
||||||
val public_key: String?
|
val public_key: String?
|
||||||
@@ -17,6 +19,7 @@ interface MempoolApi {
|
|||||||
|
|
||||||
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class OneMlNodeResponse(
|
data class OneMlNodeResponse(
|
||||||
val alias: String?,
|
val alias: String?,
|
||||||
val pub_key: String?
|
val pub_key: String?
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,17 +18,20 @@ class NodeAliasClient {
|
|||||||
.readTimeout(5, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
private val converter = JsonProvider.json
|
||||||
|
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
|
||||||
|
|
||||||
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
||||||
.baseUrl("https://mempool.space/")
|
.baseUrl("https://mempool.space/")
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(converter)
|
||||||
.build()
|
.build()
|
||||||
.create(MempoolApi::class.java)
|
.create(MempoolApi::class.java)
|
||||||
|
|
||||||
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
||||||
.baseUrl("https://1ml.com/")
|
.baseUrl("https://1ml.com/")
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(converter)
|
||||||
.build()
|
.build()
|
||||||
.create(OneMlApi::class.java)
|
.create(OneMlApi::class.java)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.appLockDataStore by preferencesDataStore(name = "app_lock_prefs")
|
||||||
|
|
||||||
|
class AppLockStore(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_APP_LOCK_ENABLED = booleanPreferencesKey("app_lock_enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults to true (locked) as required. */
|
||||||
|
val appLockEnabled: Flow<Boolean> = context.appLockDataStore.data
|
||||||
|
.map { prefs -> prefs[KEY_APP_LOCK_ENABLED] ?: true }
|
||||||
|
|
||||||
|
suspend fun setAppLockEnabled(enabled: Boolean) {
|
||||||
|
context.appLockDataStore.edit { prefs ->
|
||||||
|
prefs[KEY_APP_LOCK_ENABLED] = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
class ContactRepository(app: Application) {
|
||||||
|
private val dao = AppDatabase.getInstance(app).contactDao()
|
||||||
|
|
||||||
|
// --- Observe ---
|
||||||
|
|
||||||
|
fun observeAllContacts(): Flow<List<ContactWithAddresses>> =
|
||||||
|
dao.observeAllWithAddresses()
|
||||||
|
|
||||||
|
fun observeContact(id: String): Flow<ContactWithAddresses?> =
|
||||||
|
dao.observeById(id)
|
||||||
|
|
||||||
|
// --- Create ---
|
||||||
|
|
||||||
|
suspend fun createContact(
|
||||||
|
localAlias: String,
|
||||||
|
lnAddress: String? = null,
|
||||||
|
lnUrl: String? = null,
|
||||||
|
pictureUrl: String? = null
|
||||||
|
): ContactEntity {
|
||||||
|
val contact = ContactEntity(
|
||||||
|
localAlias = localAlias,
|
||||||
|
displayName = null,
|
||||||
|
name = null,
|
||||||
|
pictureUrl = pictureUrl,
|
||||||
|
nostrPubkey = null,
|
||||||
|
nip05 = null,
|
||||||
|
about = null,
|
||||||
|
lnAddress = lnAddress
|
||||||
|
)
|
||||||
|
dao.insert(contact)
|
||||||
|
|
||||||
|
// Also add to payment_addresses table if lnAddress provided
|
||||||
|
lnAddress?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LIGHTNING_ADDRESS.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lnUrl?.let {
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contact.id,
|
||||||
|
type = PaymentAddressType.LNURL.name,
|
||||||
|
address = it,
|
||||||
|
isDefault = lnAddress == null // default only if no lnAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
suspend fun updateAlias(id: String, newAlias: String) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(localAlias = newAlias, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updatePicture(id: String, pictureUrl: String?) {
|
||||||
|
val existing = dao.getById(id) ?: return
|
||||||
|
dao.update(existing.copy(pictureUrl = pictureUrl, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
suspend fun addAddress(
|
||||||
|
contactId: String,
|
||||||
|
type: PaymentAddressType,
|
||||||
|
address: String,
|
||||||
|
label: String? = null,
|
||||||
|
makeDefault: Boolean = false
|
||||||
|
) {
|
||||||
|
if (makeDefault) dao.clearDefault(contactId, type.name)
|
||||||
|
|
||||||
|
// If LIGHTNING_ADDRESS and default, also sync the fast-lookup column
|
||||||
|
if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) {
|
||||||
|
dao.getById(contactId)?.let {
|
||||||
|
dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.insertAddress(
|
||||||
|
PaymentAddressEntity(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type.name,
|
||||||
|
address = address,
|
||||||
|
label = label,
|
||||||
|
isDefault = makeDefault
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeAddress(addressId: String) {
|
||||||
|
dao.deleteAddressById(addressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
suspend fun deleteContact(id: String) {
|
||||||
|
dao.deleteById(id) // cascades to payment_addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lookup (for send flow) ---
|
||||||
|
|
||||||
|
suspend fun findByLnAddress(lnAddress: String): ContactEntity? =
|
||||||
|
dao.findByLnAddress(lnAddress)
|
||||||
|
|
||||||
|
suspend fun findByLnurl(lnurl: String): ContactEntity? =
|
||||||
|
dao.findByAddress(lnurl)
|
||||||
|
|
||||||
|
// --- Contact-Tx link ---
|
||||||
|
|
||||||
|
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>> =
|
||||||
|
dao.observeContactsForPayment(checkingId)
|
||||||
|
|
||||||
|
suspend fun linkPaymentToContact(checkingId: String, contactId: String, role: String? = null) {
|
||||||
|
dao.linkPaymentToContact(TxContactCrossRef(checkingId, contactId, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String) {
|
||||||
|
dao.unlinkPaymentFromContact(checkingId, contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>> =
|
||||||
|
dao.observePaymentIdsForContact(contactId)
|
||||||
|
|
||||||
|
fun observeAllPaymentContactNames(): Flow<Map<String, String>> =
|
||||||
|
dao.observePaymentContactNames()
|
||||||
|
.map { list -> list.associate { it.checkingId to it.contactName } }
|
||||||
|
|
||||||
|
suspend fun findCheckingIdsByContactName(query: String): List<String> =
|
||||||
|
dao.findCheckingIdsByContactName(query)
|
||||||
|
|
||||||
|
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String> =
|
||||||
|
dao.findCheckingIdsByContactIds(contactIds)
|
||||||
|
}
|
||||||
@@ -10,10 +10,11 @@ import androidx.datastore.preferences.core.floatPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.google.gson.Gson
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.builtins.serializer
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
private const val TAG = "FiatDataStore"
|
private const val TAG = "FiatDataStore"
|
||||||
@@ -27,13 +28,13 @@ private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStor
|
|||||||
class FiatDataStore(context: Context) {
|
class FiatDataStore(context: Context) {
|
||||||
|
|
||||||
private val dataStore = context.applicationContext.fiatDataStore
|
private val dataStore = context.applicationContext.fiatDataStore
|
||||||
private val gson = Gson()
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// ── Key factories ─────────────────────────────────────────────────────────
|
// ── Key factories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
|
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
|
||||||
private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency")
|
private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency")
|
||||||
private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
|
private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
|
||||||
|
|
||||||
// ── Rate ──────────────────────────────────────────────────────────────────
|
// ── Rate ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,10 +88,9 @@ class FiatDataStore(context: Context) {
|
|||||||
} else throw e
|
} else throw e
|
||||||
}
|
}
|
||||||
.first()
|
.first()
|
||||||
val json = prefs[currenciesKey] ?: return null
|
val jsonString = prefs[currenciesKey] ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val type = object : TypeToken<List<String>>() {}.type
|
val list = json.decodeFromString(ListSerializer(String.serializer()), jsonString)
|
||||||
val list: List<String> = gson.fromJson(json, type)
|
|
||||||
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
|
Timber.d("FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
|
||||||
list
|
list
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
@@ -106,7 +106,7 @@ class FiatDataStore(context: Context) {
|
|||||||
if (currencies.isEmpty()) return
|
if (currencies.isEmpty()) return
|
||||||
runCatching {
|
runCatching {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[currenciesKey] = gson.toJson(currencies)
|
prefs[currenciesKey] = json.encodeToString(ListSerializer(String.serializer()), currencies)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
|
Timber.w("FIAT [CURRENCIES] persist failed — ${e.message}")
|
||||||
|
|||||||
@@ -286,4 +286,131 @@ class LNbitsWalletRepository(
|
|||||||
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
||||||
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NWC Provider ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the map of supported NWC permission names.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcPermissions(): Map<String, Any> {
|
||||||
|
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
|
||||||
|
val result = api.getNwcPermissions(secrets.invoiceKey())
|
||||||
|
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all NWC connection keys for the current wallet.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
|
||||||
|
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
|
||||||
|
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
|
||||||
|
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single NWC connection key by its public key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
includeExpired : Boolean,
|
||||||
|
refreshLastUsed : Boolean
|
||||||
|
): NwcGetResponse {
|
||||||
|
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…")
|
||||||
|
val result = api.getNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
includeExpired = includeExpired,
|
||||||
|
refreshLastUsed = refreshLastUsed
|
||||||
|
)
|
||||||
|
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers / creates a new NWC connection key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun registerNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
request : NwcRegistrationRequest
|
||||||
|
): NwcGetResponse {
|
||||||
|
Timber.d(
|
||||||
|
"%snull", "NWC [REGISTER ] pubkey=${pubkey.take(16)}… " +
|
||||||
|
"permissions=${request.permissions} "
|
||||||
|
)
|
||||||
|
val result = api.registerNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
request = request
|
||||||
|
)
|
||||||
|
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an NWC connection key by its public key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun deleteNwcKey(pubkey: String) {
|
||||||
|
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}…")
|
||||||
|
api.deleteNwcKey(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
pubkey = pubkey
|
||||||
|
)
|
||||||
|
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcPairingUrl(secret: String): String {
|
||||||
|
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}…")
|
||||||
|
val url = api.getNwcPairingUrl(
|
||||||
|
invoiceKey = secrets.invoiceKey(),
|
||||||
|
secret = secret
|
||||||
|
)
|
||||||
|
Timber.i("NWC [PAIRING ] pairing URL received ✓")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all extension configuration settings.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcConfig(): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG ] fetching all config settings")
|
||||||
|
val result = api.getNwcConfig(secrets.adminKey())
|
||||||
|
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single configuration value by key.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG ] fetching config key='$key'")
|
||||||
|
val result = api.getNwcConfigValue(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
key = key
|
||||||
|
)
|
||||||
|
Timber.i("NWC [CONFIG ] received value for key='$key'")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets / updates one or more configuration values.
|
||||||
|
* Requires admin key.
|
||||||
|
*/
|
||||||
|
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
|
||||||
|
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
|
||||||
|
val result = api.setNwcConfig(
|
||||||
|
adminKey = secrets.adminKey(),
|
||||||
|
config = config
|
||||||
|
)
|
||||||
|
Timber.i("NWC [CONFIG SET] config updated ✓")
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ class LightningAddressStore(private val context: Context) {
|
|||||||
.map { prefs ->
|
.map { prefs ->
|
||||||
prefs[KEY_ADDRESS].also { address ->
|
prefs[KEY_ADDRESS].also { address ->
|
||||||
_currentAddress = address
|
_currentAddress = address
|
||||||
Timber.d("LNADDR [CACHE ] read from cache: " +
|
Timber.d("LNADDR [CACHE ] read from cache: %s", if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
|
||||||
if (address != null) "\"$address\"" else "(empty — nothing cached yet)") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun isCacheStale(): Boolean {
|
suspend fun isCacheStale(): Boolean {
|
||||||
@@ -53,9 +52,8 @@ class LightningAddressStore(private val context: Context) {
|
|||||||
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
|
val fetchedAt = prefs[KEY_FETCHED_AT] ?: 0L
|
||||||
val age = System.currentTimeMillis() - fetchedAt
|
val age = System.currentTimeMillis() - fetchedAt
|
||||||
val isStale = address == null || age > LNADDRESS_TTL_MS
|
val isStale = address == null || age > LNADDRESS_TTL_MS
|
||||||
Timber.d("LNADDR [TTL ] cached=${address != null} " +
|
Timber.d("%s%s", "LNADDR [TTL ] cached=${address != null} " +
|
||||||
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s " +
|
"age=${age / 1000}s ttl=${LNADDRESS_TTL_MS / 1000}s ", if (isStale) "→ cache stale, will fetch from API"
|
||||||
if (isStale) "→ cache stale, will fetch from API"
|
|
||||||
else "→ cache fresh, skipping API call")
|
else "→ cache fresh, skipping API call")
|
||||||
isStale
|
isStale
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.bitcointxoko.gudariwallet.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||||
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
||||||
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
|
import com.bitcointxoko.gudariwallet.data.db.LnurlCacheEntity
|
||||||
@@ -13,7 +13,7 @@ private const val TAG = "LnurlCacheRepository"
|
|||||||
class LnurlCacheRepository(context: Context) {
|
class LnurlCacheRepository(context: Context) {
|
||||||
|
|
||||||
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
|
private val dao = AppDatabase.getInstance(context).lnurlCacheDao()
|
||||||
private val gson = GsonProvider.gson
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// Fast in-memory path — avoids a DB round-trip for keys already loaded
|
// Fast in-memory path — avoids a DB round-trip for keys already loaded
|
||||||
// this session. Mirrors the pattern used in PaymentCacheRepository.
|
// this session. Mirrors the pattern used in PaymentCacheRepository.
|
||||||
@@ -39,7 +39,7 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = deserialise(entity.responseJson) ?: run {
|
val response = deserialize(entity.responseJson) ?: run {
|
||||||
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
|
Timber.w("LNURL [DESER ERR ] $key — removing corrupt entry")
|
||||||
evict(key)
|
evict(key)
|
||||||
return null
|
return null
|
||||||
@@ -62,13 +62,13 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
*/
|
*/
|
||||||
suspend fun put(key: String, response: LnurlScanResponse) {
|
suspend fun put(key: String, response: LnurlScanResponse) {
|
||||||
val disposable = response.disposable
|
val disposable = response.disposable
|
||||||
if ( disposable == true) {
|
if (disposable == true) {
|
||||||
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
|
Timber.d("LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val entity = LnurlCacheEntity(
|
val entity = LnurlCacheEntity(
|
||||||
key = key,
|
key = key,
|
||||||
responseJson = gson.toJson(response),
|
responseJson = json.encodeToString(LnurlScanResponse.serializer(), response),
|
||||||
savedAt = System.currentTimeMillis()
|
savedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -104,7 +104,7 @@ class LnurlCacheRepository(context: Context) {
|
|||||||
runCatching { dao.deleteByKey(key) }
|
runCatching { dao.deleteByKey(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserialise(json: String): LnurlScanResponse? =
|
private fun deserialize(jsonString: String): LnurlScanResponse? =
|
||||||
runCatching { gson.fromJson(json, LnurlScanResponse::class.java) }
|
runCatching { json.decodeFromString(LnurlScanResponse.serializer(), jsonString) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcKey
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcBudgetEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcKeyDao
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.NwcKeyEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
class NwcCacheRepository(app: Application) {
|
||||||
|
private val dao: NwcKeyDao = AppDatabase.getInstance(app).nwcKeyDao()
|
||||||
|
|
||||||
|
// ── Observe (for ViewModels to collect) ───────────────────────────
|
||||||
|
|
||||||
|
fun observeKeys(): Flow<List<NwcKeyEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
fun observeKey(pubkey: String): Flow<NwcKeyEntity?> = dao.observeKey(pubkey)
|
||||||
|
|
||||||
|
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>> =
|
||||||
|
dao.observeBudgets(pubkey)
|
||||||
|
|
||||||
|
// ── Sync (called after network fetches) ───────────────────────────
|
||||||
|
// One-shot synchronous read — for startup population only
|
||||||
|
suspend fun getKeysOnce(): List<NwcGetResponse> =
|
||||||
|
dao.getAllKeys().map { it.toDomain().toGetResponse() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart sync for the full key list.
|
||||||
|
*
|
||||||
|
* - New keys (not yet cached) → full insert
|
||||||
|
* - Existing keys → update last_used only (all other fields are immutable)
|
||||||
|
* - Keys present in cache but absent from server response → evict
|
||||||
|
*
|
||||||
|
* Never overwrites immutable fields on existing rows.
|
||||||
|
*/
|
||||||
|
suspend fun syncKeys(responses: List<NwcGetResponse>) {
|
||||||
|
val incoming = responses.associateBy { it.data.pubkey }
|
||||||
|
val cached = dao.getAllPubkeys().toSet()
|
||||||
|
|
||||||
|
dao.syncTransaction {
|
||||||
|
val newPubkeys = incoming.keys - cached
|
||||||
|
newPubkeys.forEach { pubkey ->
|
||||||
|
val response = incoming.getValue(pubkey)
|
||||||
|
dao.insertKey(response.data.toEntity())
|
||||||
|
dao.insertBudgets(response.budgets.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingPubkeys = incoming.keys intersect cached
|
||||||
|
existingPubkeys.forEach { pubkey ->
|
||||||
|
dao.updateLastUsed(pubkey, incoming.getValue(pubkey).data.last_used)
|
||||||
|
}
|
||||||
|
|
||||||
|
val removedPubkeys = cached - incoming.keys
|
||||||
|
if (removedPubkeys.isNotEmpty()) {
|
||||||
|
dao.deleteKeys(removedPubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single key response into the cache.
|
||||||
|
* Used after registerNwcKey() — inserts the new key exactly once.
|
||||||
|
* IGNORE conflict strategy on the DAO ensures this is a no-op if
|
||||||
|
* somehow called twice for the same pubkey.
|
||||||
|
*/
|
||||||
|
suspend fun saveKey(response: NwcGetResponse) {
|
||||||
|
dao.insertKey(response.data.toEntity())
|
||||||
|
dao.insertBudgets(response.budgets.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only last_used for a single key.
|
||||||
|
* Called after getNwcKey(refreshLastUsed = true) on the detail screen.
|
||||||
|
*/
|
||||||
|
suspend fun refreshLastUsed(pubkey: String, lastUsed: Long) {
|
||||||
|
dao.updateLastUsed(pubkey, lastUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a key from the cache.
|
||||||
|
* Called after a successful deleteNwcKey() network call.
|
||||||
|
* CASCADE on the FK removes associated budgets automatically.
|
||||||
|
*/
|
||||||
|
suspend fun deleteKey(pubkey: String) {
|
||||||
|
dao.deleteKey(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mappers ───────────────────────────────────────────────────────────
|
||||||
|
// Kept in this file — they exist solely to serve NwcCacheRepository.
|
||||||
|
// If mappers grow (e.g. reverse domain→entity direction is needed),
|
||||||
|
// extract to NwcEntityMappers.kt.
|
||||||
|
|
||||||
|
private fun NwcKey.toEntity(): NwcKeyEntity = NwcKeyEntity(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expiresAt = expires_at,
|
||||||
|
permissions = permissions,
|
||||||
|
createdAt = created_at,
|
||||||
|
lastUsed = last_used,
|
||||||
|
cachedAt = System.currentTimeMillis() / 1000L
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun NwcBudget.toEntity(): NwcBudgetEntity = NwcBudgetEntity(
|
||||||
|
id = id,
|
||||||
|
pubkey = pubkey,
|
||||||
|
budgetMsats = budget_msats,
|
||||||
|
refreshWindow = refresh_window,
|
||||||
|
createdAt = created_at,
|
||||||
|
usedBudgetMsats = used_budget_msats
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcKeyEntity.toDomain(): NwcKey = NwcKey(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expires_at = expiresAt,
|
||||||
|
permissions = permissions,
|
||||||
|
created_at = createdAt,
|
||||||
|
last_used = lastUsed
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcBudgetEntity.toDomain(): NwcBudget = NwcBudget(
|
||||||
|
id = id,
|
||||||
|
pubkey = pubkey,
|
||||||
|
budget_msats = budgetMsats,
|
||||||
|
refresh_window = refreshWindow,
|
||||||
|
created_at = createdAt,
|
||||||
|
used_budget_msats = usedBudgetMsats
|
||||||
|
)
|
||||||
|
|
||||||
|
fun NwcKey.toGetResponse() = NwcGetResponse(data = this, budgets = emptyList())
|
||||||
|
|
||||||
@@ -109,9 +109,8 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
minCreatedAt = filter.minCreatedAt,
|
minCreatedAt = filter.minCreatedAt,
|
||||||
maxCreatedAt = filter.maxCreatedAt
|
maxCreatedAt = filter.maxCreatedAt
|
||||||
).map { it.toDomain() }.also {
|
).map { it.toDomain() }.also {
|
||||||
Timber.d("PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
|
Timber.d("%snull", "PAYMENTS [QUERY ] q=$searchQuery status=$statusPattern " +
|
||||||
"amt=${filter.minAmountSat}–${filter.maxAmountSat} " +
|
"amt=${filter.minAmountSat}–${filter.maxAmountSat} ")
|
||||||
"date=${filter.minCreatedAt}–${filter.maxCreatedAt} → ${it.size} rows")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,4 +159,34 @@ class PaymentCacheRepository(context: Context) {
|
|||||||
dao.deleteAll()
|
dao.deleteAll()
|
||||||
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
Timber.d("PAYMENTS [CLEARED ] cache wiped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch payments by checkingId with the full PaymentFilter applied.
|
||||||
|
* Used by HistoryViewModel for contact name search supplement and
|
||||||
|
* contact ID filter. Amount/date/status are applied in SQL;
|
||||||
|
* direction and type are handled in-memory afterwards (same as queryAll).
|
||||||
|
*/
|
||||||
|
suspend fun queryByCheckingIds(
|
||||||
|
checkingIds : List<String>,
|
||||||
|
filter : PaymentFilter
|
||||||
|
): List<PaymentRecord> {
|
||||||
|
if (checkingIds.isEmpty()) return emptyList()
|
||||||
|
val statusPattern: String? = if (filter.statuses.isEmpty()) null else {
|
||||||
|
filter.statuses.flatMap { s ->
|
||||||
|
when (s) {
|
||||||
|
StatusFilter.COMPLETED -> listOf("success", "complete", "paid")
|
||||||
|
StatusFilter.PENDING -> listOf("pending", "in_flight", "inflight")
|
||||||
|
StatusFilter.FAILED -> listOf("failed", "error", "expired")
|
||||||
|
}
|
||||||
|
}.joinToString(",")
|
||||||
|
}
|
||||||
|
return dao.getByCheckingIdsFiltered(
|
||||||
|
checkingIds = checkingIds,
|
||||||
|
minAmountMsat = filter.minAmountSat?.let { it * 1000L },
|
||||||
|
maxAmountMsat = filter.maxAmountSat?.let { it * 1000L },
|
||||||
|
minCreatedAt = filter.minCreatedAt,
|
||||||
|
maxCreatedAt = filter.maxCreatedAt,
|
||||||
|
statusPattern = statusPattern
|
||||||
|
).map { it.toDomain() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
|
||||||
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
|
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
@@ -41,4 +43,20 @@ interface WalletRepository {
|
|||||||
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||||
suspend fun getFiatRate(currency: String): Double
|
suspend fun getFiatRate(currency: String): Double
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
suspend fun getNwcPermissions(): Map<String, Any>
|
||||||
|
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
|
||||||
|
suspend fun getNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
includeExpired : Boolean = false,
|
||||||
|
refreshLastUsed : Boolean = false
|
||||||
|
): NwcGetResponse
|
||||||
|
suspend fun registerNwcKey(
|
||||||
|
pubkey : String,
|
||||||
|
request : NwcRegistrationRequest
|
||||||
|
): NwcGetResponse
|
||||||
|
suspend fun deleteNwcKey(pubkey: String)
|
||||||
|
suspend fun getNwcPairingUrl(secret: String): String
|
||||||
|
suspend fun getNwcConfig(): Map<String, Any>
|
||||||
|
suspend fun getNwcConfigValue(key: String): Map<String, Any>
|
||||||
|
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,13 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
PaymentRecordEntity::class,
|
PaymentRecordEntity::class,
|
||||||
LnurlCacheEntity::class,
|
LnurlCacheEntity::class,
|
||||||
NodeAliasCacheEntity::class,
|
NodeAliasCacheEntity::class,
|
||||||
|
NwcKeyEntity::class,
|
||||||
|
NwcBudgetEntity::class,
|
||||||
|
ContactEntity::class,
|
||||||
|
PaymentAddressEntity::class,
|
||||||
|
TxContactCrossRef::class,
|
||||||
],
|
],
|
||||||
version = 6,
|
version = 9,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(PaymentConverters::class)
|
@TypeConverters(PaymentConverters::class)
|
||||||
@@ -23,6 +28,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun paymentDao(): PaymentDao
|
abstract fun paymentDao(): PaymentDao
|
||||||
abstract fun lnurlCacheDao(): LnurlCacheDao
|
abstract fun lnurlCacheDao(): LnurlCacheDao
|
||||||
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
|
||||||
|
abstract fun nwcKeyDao(): NwcKeyDao
|
||||||
|
abstract fun contactDao(): ContactDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: AppDatabase? = null
|
@Volatile private var INSTANCE: AppDatabase? = null
|
||||||
@@ -86,6 +93,108 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nwc_keys (
|
||||||
|
pubkey TEXT NOT NULL PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used INTEGER NOT NULL,
|
||||||
|
cached_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS nwc_budgets (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
budget_msats INTEGER NOT NULL,
|
||||||
|
refresh_window INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
used_budget_msats INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(pubkey) REFERENCES nwc_keys(pubkey)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_nwc_budgets_pubkey
|
||||||
|
ON nwc_budgets(pubkey)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
localAlias TEXT,
|
||||||
|
displayName TEXT,
|
||||||
|
name TEXT,
|
||||||
|
pictureUrl TEXT,
|
||||||
|
nostrPubkey TEXT,
|
||||||
|
nip05 TEXT,
|
||||||
|
about TEXT,
|
||||||
|
lnAddress TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
updatedAt INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_nostrPubkey
|
||||||
|
ON contacts(nostrPubkey)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_contacts_lnAddress
|
||||||
|
ON contacts(lnAddress)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_addresses (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
isDefault INTEGER NOT NULL DEFAULT 0,
|
||||||
|
addedAt INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(contactId) REFERENCES contacts(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE INDEX IF NOT EXISTS index_payment_addresses_contactId
|
||||||
|
ON payment_addresses(contactId)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tx_contact_links (
|
||||||
|
checkingId TEXT NOT NULL,
|
||||||
|
contactId TEXT NOT NULL,
|
||||||
|
role TEXT,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (checkingId, contactId),
|
||||||
|
FOREIGN KEY (checkingId) REFERENCES payment_records(checkingId) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (contactId) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_checkingId ON tx_contact_links(checkingId)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS index_tx_contact_links_contactId ON tx_contact_links(contactId)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getInstance(context: Context): AppDatabase =
|
fun getInstance(context: Context): AppDatabase =
|
||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
INSTANCE ?: Room.databaseBuilder(
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
@@ -93,7 +202,16 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"gudari_wallet.db"
|
"gudari_wallet.db"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
|
.addMigrations(
|
||||||
|
MIGRATION_1_2,
|
||||||
|
MIGRATION_2_3,
|
||||||
|
MIGRATION_3_4,
|
||||||
|
MIGRATION_4_5,
|
||||||
|
MIGRATION_5_6,
|
||||||
|
MIGRATION_6_7,
|
||||||
|
MIGRATION_7_8,
|
||||||
|
MIGRATION_8_9
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
data class ContactWithAddresses(
|
||||||
|
@Embedded val contact: ContactEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "id",
|
||||||
|
entityColumn = "contactId"
|
||||||
|
)
|
||||||
|
val addresses: List<PaymentAddressEntity>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ContactDao {
|
||||||
|
|
||||||
|
// --- Contacts ---
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAll(): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts ORDER BY COALESCE(localAlias, displayName, name) ASC")
|
||||||
|
fun observeAllWithAddresses(): Flow<List<ContactWithAddresses>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id")
|
||||||
|
fun observeById(id: String): Flow<ContactWithAddresses?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun getById(id: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT c.* FROM contacts c INNER JOIN payment_addresses pa ON pa.contactId = c.id WHERE pa.address = :address LIMIT 1")
|
||||||
|
suspend fun findByAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE lnAddress = :address LIMIT 1")
|
||||||
|
suspend fun findByLnAddress(address: String): ContactEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM contacts WHERE nostrPubkey = :pubkey LIMIT 1")
|
||||||
|
suspend fun findByNostrPubkey(pubkey: String): ContactEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
suspend fun insert(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(contact: ContactEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM contacts WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
// --- Payment addresses ---
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun updateAddress(address: PaymentAddressEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM payment_addresses WHERE id = :id")
|
||||||
|
suspend fun deleteAddressById(id: String)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_addresses WHERE contactId = :contactId")
|
||||||
|
fun observeAddressesForContact(contactId: String): Flow<List<PaymentAddressEntity>>
|
||||||
|
|
||||||
|
// Clear the isDefault flag for a type before setting a new default
|
||||||
|
@Query("""
|
||||||
|
UPDATE payment_addresses
|
||||||
|
SET isDefault = 0
|
||||||
|
WHERE contactId = :contactId AND type = :type
|
||||||
|
""")
|
||||||
|
suspend fun clearDefault(contactId: String, type: String)
|
||||||
|
|
||||||
|
// Insert a link
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun linkPaymentToContact(crossRef: TxContactCrossRef)
|
||||||
|
|
||||||
|
// Delete a link
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId AND contactId = :contactId")
|
||||||
|
suspend fun unlinkPaymentFromContact(checkingId: String, contactId: String)
|
||||||
|
|
||||||
|
// Delete all contact links for a payment
|
||||||
|
@Query("DELETE FROM tx_contact_links WHERE checkingId = :checkingId")
|
||||||
|
suspend fun clearContactsForPayment(checkingId: String)
|
||||||
|
|
||||||
|
// Get all contacts linked to a payment
|
||||||
|
@Query("""
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
INNER JOIN tx_contact_links tcl ON tcl.contactId = c.id
|
||||||
|
WHERE tcl.checkingId = :checkingId
|
||||||
|
""")
|
||||||
|
fun observeContactsForPayment(checkingId: String): Flow<List<ContactEntity>>
|
||||||
|
|
||||||
|
// Get all payments linked to a contact
|
||||||
|
@Query("""
|
||||||
|
SELECT checkingId FROM tx_contact_links
|
||||||
|
WHERE contactId = :contactId
|
||||||
|
ORDER BY createdAt DESC
|
||||||
|
""")
|
||||||
|
fun observePaymentIdsForContact(contactId: String): Flow<List<String>>
|
||||||
|
|
||||||
|
// Returns all (checkingId, displayName) pairs that have a linked contact,
|
||||||
|
// so HistoryViewModel can build its lookup map in a single reactive query.
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.checkingId AS checkingId,
|
||||||
|
COALESCE(c.localAlias, c.displayName, c.name) AS contactName
|
||||||
|
FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
""")
|
||||||
|
fun observePaymentContactNames(): Flow<List<PaymentContactName>>
|
||||||
|
|
||||||
|
// For text search matching contact names — returns checkingIds of matched payments
|
||||||
|
@Query("""
|
||||||
|
SELECT tcl.checkingId FROM tx_contact_links tcl
|
||||||
|
INNER JOIN contacts c ON c.id = tcl.contactId
|
||||||
|
WHERE COALESCE(c.localAlias, c.displayName, c.name) LIKE '%' || :query || '%'
|
||||||
|
""")
|
||||||
|
suspend fun findCheckingIdsByContactName(query: String): List<String>
|
||||||
|
|
||||||
|
// For filter-by-contact — returns checkingIds for specific contact IDs
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT checkingId FROM tx_contact_links
|
||||||
|
WHERE contactId IN (:contactIds)
|
||||||
|
""")
|
||||||
|
suspend fun findCheckingIdsByContactIds(contactIds: List<String>): List<String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "contacts",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["nostrPubkey"], unique = true), // null-safe: SQLite allows multiple NULL in unique index
|
||||||
|
Index(value = ["lnAddress"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class ContactEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
// --- Display identity ---
|
||||||
|
val localAlias: String?, // user-set name — highest display priority
|
||||||
|
val displayName: String?, // future: from nostr kind 0 display_name
|
||||||
|
val name: String?, // future: from nostr kind 0 name
|
||||||
|
val pictureUrl: String?, // future: from nostr kind 0 picture
|
||||||
|
|
||||||
|
// --- Nostr identity (all nullable — not used for manual contacts yet) ---
|
||||||
|
val nostrPubkey: String?, // 32-byte hex; unique index
|
||||||
|
val nip05: String?, // e.g. user@domain.com
|
||||||
|
val about: String?, // bio / notes
|
||||||
|
|
||||||
|
// --- Payment fast-access (denormalized for single-row lookup) ---
|
||||||
|
val lnAddress: String?, // primary Lightning Address (lud16)
|
||||||
|
|
||||||
|
// --- Metadata ---
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "nwc_budgets",
|
||||||
|
foreignKeys = [ForeignKey(
|
||||||
|
entity = NwcKeyEntity::class,
|
||||||
|
parentColumns = ["pubkey"],
|
||||||
|
childColumns = ["pubkey"],
|
||||||
|
onDelete = ForeignKey.CASCADE // auto-cleans budgets when key is deleted
|
||||||
|
)],
|
||||||
|
indices = [Index("pubkey")]
|
||||||
|
)
|
||||||
|
data class NwcBudgetEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "id")
|
||||||
|
val id: Int,
|
||||||
|
@ColumnInfo(name = "pubkey")
|
||||||
|
val pubkey: String,
|
||||||
|
@ColumnInfo(name = "budget_msats")
|
||||||
|
val budgetMsats: Long,
|
||||||
|
@ColumnInfo(name = "refresh_window")
|
||||||
|
val refreshWindow: Int,
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "used_budget_msats")
|
||||||
|
val usedBudgetMsats: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface NwcKeyDao {
|
||||||
|
|
||||||
|
// ── Observe (UI layer subscriptions) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of all cached keys, newest first.
|
||||||
|
* Room re-emits automatically on any write — this is the only
|
||||||
|
* channel the list screen needs to collect.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
|
||||||
|
fun observeAll(): Flow<List<NwcKeyEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of a single key — for the detail screen.
|
||||||
|
* Emits null if the key has been evicted (e.g. after a delete).
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys WHERE pubkey = :pubkey")
|
||||||
|
fun observeKey(pubkey: String): Flow<NwcKeyEntity?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live stream of budgets for a given key — for the detail screen.
|
||||||
|
* CASCADE delete on the parent ensures this automatically becomes
|
||||||
|
* empty when the parent key is deleted.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_budgets WHERE pubkey = :pubkey")
|
||||||
|
fun observeBudgets(pubkey: String): Flow<List<NwcBudgetEntity>>
|
||||||
|
|
||||||
|
// ── One-shot reads (sync logic, not UI) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight membership check used by syncKeys() to determine
|
||||||
|
* which incoming keys are new vs. already cached.
|
||||||
|
* Returns only pubkeys — avoids loading full rows unnecessarily.
|
||||||
|
*/
|
||||||
|
@Query("SELECT pubkey FROM nwc_keys")
|
||||||
|
suspend fun getAllPubkeys(): List<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot full read of all cached keys — used for instant startup
|
||||||
|
* population before the Flow subscription is active.
|
||||||
|
* Same query as observeAll() but returns a plain list instead of Flow.
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM nwc_keys ORDER BY created_at DESC")
|
||||||
|
suspend fun getAllKeys(): List<NwcKeyEntity>
|
||||||
|
|
||||||
|
// ── Writes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a brand-new key. IGNORE on conflict means an existing row
|
||||||
|
* is never overwritten by this path — all immutable fields are safe.
|
||||||
|
* Used exclusively when a key is first seen (creation or first sync).
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertKey(key: NwcKeyEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun syncTransaction(block: suspend () -> Unit) = block()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert budgets for a new key. Also IGNORE on conflict —
|
||||||
|
* budget fields are immutable post-creation.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun insertBudgets(budgets: List<NwcBudgetEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ONLY last_used — the single mutable field on a key.
|
||||||
|
* This is the only update path that should ever run post-creation.
|
||||||
|
*/
|
||||||
|
@Query("UPDATE nwc_keys SET last_used = :lastUsed WHERE pubkey = :pubkey")
|
||||||
|
suspend fun updateLastUsed(pubkey: String, lastUsed: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a key by pubkey. The CASCADE foreign key on nwc_budgets
|
||||||
|
* automatically deletes all associated budget rows — no manual
|
||||||
|
* budget cleanup needed.
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM nwc_keys WHERE pubkey = :pubkey")
|
||||||
|
suspend fun deleteKey(pubkey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict a set of keys in one statement — used by syncKeys() to
|
||||||
|
* remove server-deleted keys without issuing N individual deletes.
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM nwc_keys WHERE pubkey IN (:pubkeys)")
|
||||||
|
suspend fun deleteKeys(pubkeys: Collection<String>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "nwc_keys")
|
||||||
|
data class NwcKeyEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = "pubkey")
|
||||||
|
val pubkey: String,
|
||||||
|
@ColumnInfo(name = "wallet")
|
||||||
|
val wallet: String,
|
||||||
|
@ColumnInfo(name = "description")
|
||||||
|
val description: String,
|
||||||
|
@ColumnInfo(name = "expires_at")
|
||||||
|
val expiresAt: Long,
|
||||||
|
@ColumnInfo(name = "permissions")
|
||||||
|
val permissions: String, // stored as comma-separated string
|
||||||
|
@ColumnInfo(name = "created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "last_used")
|
||||||
|
val lastUsed: Long,
|
||||||
|
@ColumnInfo(name = "cached_at")
|
||||||
|
val cachedAt: Long // epoch-seconds, used for staleness check if desired
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "payment_addresses",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ContactEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["contactId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [Index("contactId")]
|
||||||
|
)
|
||||||
|
data class PaymentAddressEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
val contactId: String, // FK → contacts.id
|
||||||
|
|
||||||
|
val type: String, // PaymentAddressType.name (stored as String)
|
||||||
|
val address: String, // the raw address value
|
||||||
|
val label: String? = null, // e.g. "personal", "savings"
|
||||||
|
val isDefault: Boolean = false,
|
||||||
|
val addedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep as a simple enum in the same file or a separate file
|
||||||
|
enum class PaymentAddressType {
|
||||||
|
LIGHTNING_ADDRESS, // user@domain.com (lud16)
|
||||||
|
LNURL, // bech32 lnurl (lud06)
|
||||||
|
ON_CHAIN, // Bitcoin address
|
||||||
|
SILENT_PAYMENT, // BIP-352 sp1...
|
||||||
|
BOLT12_OFFER, // lno...
|
||||||
|
BIP353 // ₿user@domain.com
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
data class PaymentContactName(
|
||||||
|
val checkingId : String,
|
||||||
|
val contactName : String
|
||||||
|
)
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider
|
|
||||||
|
|
||||||
object PaymentConverters {
|
object PaymentConverters {
|
||||||
private val gson = GsonProvider.gson
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun fromExtra(extra: PaymentExtra?): String? =
|
fun fromExtra(extra: PaymentExtra?): String? =
|
||||||
extra?.let { gson.toJson(it) }
|
extra?.let { json.encodeToString(PaymentExtra.serializer(), it) }
|
||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun toExtra(json: String?): PaymentExtra? =
|
fun toExtra(string: String?): PaymentExtra? =
|
||||||
json?.let { gson.fromJson(it, PaymentExtra::class.java) }
|
string?.let { json.decodeFromString(PaymentExtra.serializer(), it) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ interface PaymentDao {
|
|||||||
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
@Query("SELECT * FROM payment_records WHERE checkingId = :checkingId LIMIT 1")
|
||||||
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
suspend fun getByCheckingId(checkingId: String): PaymentRecordEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM payment_records WHERE checkingId IN (:checkingIds) ORDER BY createdAt DESC")
|
||||||
|
suspend fun getByCheckingIds(checkingIds: List<String>): List<PaymentRecordEntity>
|
||||||
|
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM payment_records
|
||||||
|
WHERE checkingId IN (:checkingIds)
|
||||||
|
AND (:minAmountMsat IS NULL OR ABS(amountMsat) >= :minAmountMsat)
|
||||||
|
AND (:maxAmountMsat IS NULL OR ABS(amountMsat) <= :maxAmountMsat)
|
||||||
|
AND (:minCreatedAt IS NULL OR createdAt >= :minCreatedAt)
|
||||||
|
AND (:maxCreatedAt IS NULL OR createdAt <= :maxCreatedAt)
|
||||||
|
AND (:statusPattern IS NULL OR
|
||||||
|
',' || :statusPattern || ',' LIKE '%,' || lower(status) || ',%')
|
||||||
|
ORDER BY createdAt DESC, time DESC
|
||||||
|
""")
|
||||||
|
suspend fun getByCheckingIdsFiltered(
|
||||||
|
checkingIds : List<String>,
|
||||||
|
minAmountMsat : Long? = null,
|
||||||
|
maxAmountMsat : Long? = null,
|
||||||
|
minCreatedAt : Long? = null,
|
||||||
|
maxCreatedAt : Long? = null,
|
||||||
|
statusPattern : String? = null
|
||||||
|
): List<PaymentRecordEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM payment_records")
|
@Query("DELETE FROM payment_records")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data.db
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import com.bitcointxoko.gudariwallet.api.GsonProvider.gson
|
import com.bitcointxoko.gudariwallet.api.JsonProvider
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentExtra
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
|
|
||||||
|
|
||||||
private const val TAG = "PaymentMappers"
|
private const val TAG = "PaymentMappers"
|
||||||
|
private val json = JsonProvider.json
|
||||||
|
|
||||||
// ── Parse helper — runs once at write time, never again ──────────────────────
|
// ── Parse helper — runs once at write time, never again ──────────────────────
|
||||||
|
|
||||||
@@ -36,10 +36,10 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
|
|||||||
bolt11 = bolt11,
|
bolt11 = bolt11,
|
||||||
pending = pending,
|
pending = pending,
|
||||||
preimage = preimage,
|
preimage = preimage,
|
||||||
extra = extra?.let { gson.toJson(it) },
|
extra = extra?.let { json.encodeToString(PaymentExtra.serializer(), it) },
|
||||||
savedAt = savedAt,
|
savedAt = savedAt,
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
alias = alias
|
alias = alias
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
|
// ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
|
||||||
@@ -57,7 +57,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
|
|||||||
bolt11 = bolt11,
|
bolt11 = bolt11,
|
||||||
pending = pending,
|
pending = pending,
|
||||||
preimage = preimage,
|
preimage = preimage,
|
||||||
extra = extra?.let { gson.fromJson(it, com.bitcointxoko.gudariwallet.api.PaymentExtra::class.java) },
|
extra = extra?.let { json.decodeFromString(PaymentExtra.serializer(), it) },
|
||||||
pubkey = pubkey,
|
pubkey = pubkey,
|
||||||
alias = alias
|
alias = alias
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "tx_contact_links",
|
||||||
|
primaryKeys = ["checkingId", "contactId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = PaymentRecordEntity::class,
|
||||||
|
parentColumns = ["checkingId"],
|
||||||
|
childColumns = ["checkingId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
),
|
||||||
|
ForeignKey(
|
||||||
|
entity = ContactEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["contactId"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
|
)
|
||||||
|
],
|
||||||
|
indices = [
|
||||||
|
Index("checkingId"),
|
||||||
|
Index("contactId")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class TxContactCrossRef(
|
||||||
|
val checkingId : String,
|
||||||
|
val contactId : String,
|
||||||
|
val role : String? = null,
|
||||||
|
val createdAt : Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.bitcointxoko.gudariwallet.i18n
|
package com.bitcointxoko.gudariwallet.i18n
|
||||||
|
|
||||||
import cafe.adriel.lyricist.LyricistStrings
|
import cafe.adriel.lyricist.LyricistStrings
|
||||||
import kotlin.String
|
|
||||||
|
|
||||||
@LyricistStrings(languageTag = "en", default = true)
|
@LyricistStrings(languageTag = "en", default = true)
|
||||||
val EnHomeStrings = AppStrings(
|
val EnHomeStrings = AppStrings(
|
||||||
@@ -55,61 +54,8 @@ val EnHomeStrings = AppStrings(
|
|||||||
details = "Details",
|
details = "Details",
|
||||||
enterValidAmount = "Enter a valid amount",
|
enterValidAmount = "Enter a valid amount",
|
||||||
satsUnit = { sats -> "$sats sats" },
|
satsUnit = { sats -> "$sats sats" },
|
||||||
|
today = "today",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "yesterday",
|
||||||
receive = "Receive",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Error",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Lightning address QR code",
|
|
||||||
lightningAddressLabel = "Lightning Address",
|
|
||||||
shareLightningAddress = "Share Lightning Address",
|
|
||||||
lightningInvoiceQrCode = "Lightning invoice QR code",
|
|
||||||
invoiceLabel = "Invoice",
|
|
||||||
shareInvoice = "Share Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Amount (sats)",
|
|
||||||
satsRange = { min, max -> "$min – $max sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Memo (optional)",
|
|
||||||
memoPlaceholder = "What's this for?",
|
|
||||||
creatingInvoice = "Creating invoice…",
|
|
||||||
createInvoice = "Create Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Waiting…",
|
|
||||||
invoiceExpired = "Invoice expired",
|
|
||||||
newInvoice = "New Invoice",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Withdraw from",
|
|
||||||
minimumSats = { min -> "Minimum is $min sats" },
|
|
||||||
maximumSats = { max -> "Maximum is $max sats" },
|
|
||||||
withdrawButton = "Withdraw",
|
|
||||||
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "Payment Received!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Reduce brightness",
|
|
||||||
brightnessBoost = "Boost brightness",
|
|
||||||
qrShare = "Share",
|
|
||||||
qrCopy = "Copy",
|
|
||||||
qrNfcStop = "Stop NFC sharing",
|
|
||||||
qrNfcStart = "Share via NFC",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "Failed to create invoice",
|
|
||||||
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
|
|
||||||
receiveVmWithdrawFailed = "Withdraw failed",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Send",
|
send = "Send",
|
||||||
@@ -196,118 +142,6 @@ val EnHomeStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
|
sendVmUnsupportedType = { tag -> "Unsupported type: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
|
sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address",
|
||||||
|
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
|
||||||
historyTitle = "Activity",
|
|
||||||
historyClose = "Close",
|
|
||||||
historySearchPayments = "Search payments",
|
|
||||||
historyFilterPayments = "Filter payments",
|
|
||||||
historyRetry = "Retry",
|
|
||||||
historyNoPayments = "No payments yet.",
|
|
||||||
historySearchPlaceholder = "Search transactions",
|
|
||||||
historySearchHelp = "Search help",
|
|
||||||
historySearchPaste = "Paste",
|
|
||||||
historySearchClear = "Clear search",
|
|
||||||
historySearchChip = { tail -> "search: …$tail" },
|
|
||||||
historyClearSearch = "Clear search",
|
|
||||||
filterDirectionOutgoing = "Outgoing",
|
|
||||||
filterDirectionIncoming = "Incoming",
|
|
||||||
filterClearDirection = "Clear direction filter",
|
|
||||||
filterStatusCompleted = "Completed",
|
|
||||||
filterStatusPending = "Pending",
|
|
||||||
filterStatusFailed = "Failed",
|
|
||||||
filterRemoveStatus = "Remove status filter",
|
|
||||||
filterRemoveType = "Remove type filter",
|
|
||||||
filterAmountRange = { min, max -> "$min–$max sats" },
|
|
||||||
filterAmountMin = { min -> "≥ $min sats" },
|
|
||||||
filterAmountMax = { max -> "≤ $max sats" },
|
|
||||||
filterClearAmount = "Clear amount filter",
|
|
||||||
filterDateThisWeek = "This week",
|
|
||||||
filterDateThisMonth = "This month",
|
|
||||||
filterDateThisYear = "This year",
|
|
||||||
filterDateRange = { from, to -> "$from–$to" },
|
|
||||||
filterDateFrom = { from -> "From $from" },
|
|
||||||
filterDateUntil = { until -> "Until $until" },
|
|
||||||
filterClearDate = "Clear date filter",
|
|
||||||
|
|
||||||
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
|
||||||
filterSheetTitle = "Filter payments",
|
|
||||||
filterSectionDirection = "Direction",
|
|
||||||
filterDirectionAll = "All",
|
|
||||||
filterSectionStatus = "Status",
|
|
||||||
filterSectionType = "Type",
|
|
||||||
filterSectionAmount = "Amount (sats)",
|
|
||||||
filterAmountMin_label = "Min",
|
|
||||||
filterAmountMax_label = "Max",
|
|
||||||
filterAmountSuffix = "sats",
|
|
||||||
filterSectionDate = "Date",
|
|
||||||
filterDateFrom_label = "From",
|
|
||||||
filterDateTo_label = "To",
|
|
||||||
filterDialogOk = "OK",
|
|
||||||
filterDialogCancel = "Cancel",
|
|
||||||
|
|
||||||
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
|
||||||
searchInfoTitle = "What can I search for?",
|
|
||||||
searchInfoSubtitle = "Type any part of the following to find a payment:",
|
|
||||||
searchInfoMemoTitle = "Memo",
|
|
||||||
searchInfoMemoDesc = "The message or description attached to a payment. " +
|
|
||||||
"For example, \"coffee\" or \"rent March\".",
|
|
||||||
searchInfoInvoiceTitle = "Invoice",
|
|
||||||
searchInfoInvoiceDesc = "The payment request you scanned or pasted to send a payment. " +
|
|
||||||
"It usually starts with \"lnbc…\". " +
|
|
||||||
"You can paste just the first or last few characters.",
|
|
||||||
searchInfoHashTitle = "Hash",
|
|
||||||
searchInfoHashDesc = "A unique code that identifies this payment on the " +
|
|
||||||
"Lightning Network. Useful if someone asks you to " +
|
|
||||||
"confirm a specific transaction.",
|
|
||||||
searchInfoPreimageTitle = "Preimage",
|
|
||||||
searchInfoPreimageDesc = "A secret code that proves a payment was received. " +
|
|
||||||
"Only available after a payment completes successfully.",
|
|
||||||
searchInfoNodeAliasTitle = "Node alias",
|
|
||||||
searchInfoNodeAliasDesc = "The name of the Lightning node you sent a payment to. " +
|
|
||||||
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
|
|
||||||
"Aliases are resolved automatically for outgoing payments.",
|
|
||||||
searchInfoNodeIdTitle = "Node ID (pubkey)",
|
|
||||||
searchInfoNodeIdDesc = "The public key of the destination node. " +
|
|
||||||
"A 66-character hex string — you can paste just the first few characters.",
|
|
||||||
searchInfoFooter = "You can paste a full value or search with just a few characters — " +
|
|
||||||
"partial matches work too.",
|
|
||||||
|
|
||||||
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
|
||||||
statusSuccess = "Success", // e.g. "Success"
|
|
||||||
statusPending = "Pending", // e.g. "Pending"
|
|
||||||
statusFailed = "Failed", // e.g. "Failed"
|
|
||||||
statusUnknown = "Unknown", // e.g. "Unknown"
|
|
||||||
detailOutgoingTitle = "Outgoing payment",
|
|
||||||
detailIncomingTitle = "Incoming payment",
|
|
||||||
detailBack = "Back",
|
|
||||||
detailCouldNotLoad = "Could not load full details",
|
|
||||||
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
|
||||||
"Fee: $feeSat sats$fiatPart$ppmPart"
|
|
||||||
},
|
|
||||||
detailSectionDetails = "Details",
|
|
||||||
detailRowDate = "Date",
|
|
||||||
detailRowMemo = "Memo",
|
|
||||||
detailRowMemoEmpty = "—",
|
|
||||||
detailRowType = "Type",
|
|
||||||
detailRowComment = "Comment",
|
|
||||||
detailSectionSuccessAction = "Success Action",
|
|
||||||
detailSuccessMessage = "Message",
|
|
||||||
detailSuccessUrl = "URL",
|
|
||||||
detailSuccessDescription = "Description",
|
|
||||||
detailSectionTechnical = "Technical",
|
|
||||||
detailRowPaymentHash = "Hash",
|
|
||||||
detailRowPreimage = "Preimage",
|
|
||||||
detailRowDestination = "Destination",
|
|
||||||
detailCopyNodeId = "Copy node ID",
|
|
||||||
detailRowBolt11 = "BOLT11",
|
|
||||||
|
|
||||||
// ── PaymentRow ────────────────────────────────────────────────────────────
|
|
||||||
paymentRowSentCD = "Sent",
|
|
||||||
paymentRowReceivedCD = "Received",
|
|
||||||
paymentRowNoMemo = "No memo",
|
|
||||||
paymentRowPending = "Pending",
|
|
||||||
paymentRowFailed = "Failed",
|
|
||||||
|
|
||||||
onboarding = OnboardingStrings(
|
onboarding = OnboardingStrings(
|
||||||
back = "Back",
|
back = "Back",
|
||||||
next = "Next",
|
next = "Next",
|
||||||
@@ -325,6 +159,7 @@ val EnHomeStrings = AppStrings(
|
|||||||
hideAdminKey = "Hide",
|
hideAdminKey = "Hide",
|
||||||
showAdminKey = "Show",
|
showAdminKey = "Show",
|
||||||
fieldsRequired = "All fields are required.",
|
fieldsRequired = "All fields are required.",
|
||||||
|
invalidKeyFormat = "Invalid key format",
|
||||||
connect = "Connect",
|
connect = "Connect",
|
||||||
notificationsTitle = "Stay in the loop",
|
notificationsTitle = "Stay in the loop",
|
||||||
notificationsSubtitle = "Get notified instantly when you receive a payment.",
|
notificationsSubtitle = "Get notified instantly when you receive a payment.",
|
||||||
@@ -352,6 +187,301 @@ val EnHomeStrings = AppStrings(
|
|||||||
scheduleSync = "Schedule sync",
|
scheduleSync = "Schedule sync",
|
||||||
schedule = "Schedule",
|
schedule = "Schedule",
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
)
|
),
|
||||||
|
|
||||||
|
receive = ReceiveStrings(
|
||||||
|
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
||||||
|
receive = "Receive",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
||||||
|
receiveError = "Error",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
||||||
|
lightningAddressQrCode = "Lightning address QR code",
|
||||||
|
lightningAddressLabel = "Lightning Address",
|
||||||
|
shareLightningAddress = "Share Lightning Address",
|
||||||
|
lightningInvoiceQrCode = "Lightning invoice QR code",
|
||||||
|
invoiceLabel = "Invoice",
|
||||||
|
shareInvoice = "Share Invoice",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
||||||
|
amountInSats = "Amount (sats)",
|
||||||
|
satsRange = { min, max -> "$min – $max sats" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
||||||
|
memoOptional = "Memo (optional)",
|
||||||
|
memoPlaceholder = "What's this for?",
|
||||||
|
creatingInvoice = "Creating invoice…",
|
||||||
|
createInvoice = "Create Invoice",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
||||||
|
waiting = "Waiting…",
|
||||||
|
invoiceExpired = "Invoice expired",
|
||||||
|
newInvoice = "New Invoice",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
||||||
|
withdrawFrom = "Withdraw from",
|
||||||
|
minimumSats = { min -> "Minimum is $min sats" },
|
||||||
|
maximumSats = { max -> "Maximum is $max sats" },
|
||||||
|
withdrawButton = "Withdraw",
|
||||||
|
withdrawButtonAmount = { amount -> "Withdraw $amount sats" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
||||||
|
paymentReceived = "Payment Received!",
|
||||||
|
|
||||||
|
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
||||||
|
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
||||||
|
|
||||||
|
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
||||||
|
brightnessReduce = "Reduce brightness",
|
||||||
|
brightnessBoost = "Boost brightness",
|
||||||
|
qrShare = "Share",
|
||||||
|
qrCopy = "Copy",
|
||||||
|
qrNfcStop = "Stop NFC sharing",
|
||||||
|
qrNfcStart = "Share via NFC",
|
||||||
|
openInApp = "Open in app",
|
||||||
|
noAppFound = "No compatible app found",
|
||||||
|
|
||||||
|
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
||||||
|
receiveVmFailedToCreateInvoice = "Failed to create invoice",
|
||||||
|
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
|
||||||
|
receiveVmWithdrawFailed = "Withdraw failed",
|
||||||
|
),
|
||||||
|
|
||||||
|
history = HistoryStrings(
|
||||||
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
|
historyTitle = "Activity",
|
||||||
|
historyClose = "Close",
|
||||||
|
historySearchPayments = "Search payments",
|
||||||
|
historyFilterPayments = "Filter payments",
|
||||||
|
historyRetry = "Retry",
|
||||||
|
historyNoPayments = "No payments yet.",
|
||||||
|
historySearchPlaceholder = "Search transactions",
|
||||||
|
historySearchHelp = "Search help",
|
||||||
|
historySearchPaste = "Paste",
|
||||||
|
historySearchClear = "Clear search",
|
||||||
|
historySearchChip = { tail -> "search: …$tail" },
|
||||||
|
historyClearSearch = "Clear search",
|
||||||
|
filterDirectionOutgoing = "Outgoing",
|
||||||
|
filterDirectionIncoming = "Incoming",
|
||||||
|
filterClearDirection = "Clear direction filter",
|
||||||
|
filterStatusCompleted = "Completed",
|
||||||
|
filterStatusPending = "Pending",
|
||||||
|
filterStatusFailed = "Failed",
|
||||||
|
filterRemoveStatus = "Remove status filter",
|
||||||
|
filterRemoveType = "Remove type filter",
|
||||||
|
filterAmountRange = { min, max -> "$min–$max sats" },
|
||||||
|
filterAmountMin = { min -> "≥ $min sats" },
|
||||||
|
filterAmountMax = { max -> "≤ $max sats" },
|
||||||
|
filterClearAmount = "Clear amount filter",
|
||||||
|
filterDateThisWeek = "This week",
|
||||||
|
filterDateThisMonth = "This month",
|
||||||
|
filterDateThisYear = "This year",
|
||||||
|
filterDateRange = { from, to -> "$from–$to" },
|
||||||
|
filterDateFrom = { from -> "From $from" },
|
||||||
|
filterDateUntil = { until -> "Until $until" },
|
||||||
|
filterClearDate = "Clear date filter",
|
||||||
|
|
||||||
|
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
||||||
|
filterSheetTitle = "Filter payments",
|
||||||
|
filterSectionDirection = "Direction",
|
||||||
|
filterDirectionAll = "All",
|
||||||
|
filterSectionStatus = "Status",
|
||||||
|
filterSectionType = "Type",
|
||||||
|
filterSectionAmount = "Amount (sats)",
|
||||||
|
filterAmountMin_label = "Min",
|
||||||
|
filterAmountMax_label = "Max",
|
||||||
|
filterAmountSuffix = "sats",
|
||||||
|
filterSectionDate = "Date",
|
||||||
|
filterDateFrom_label = "From",
|
||||||
|
filterDateTo_label = "To",
|
||||||
|
filterDialogOk = "OK",
|
||||||
|
filterDialogCancel = "Cancel",
|
||||||
|
|
||||||
|
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
||||||
|
searchInfoTitle = "What can I search for?",
|
||||||
|
searchInfoSubtitle = "Type any part of the following to find a payment:",
|
||||||
|
searchInfoMemoTitle = "Memo",
|
||||||
|
searchInfoMemoDesc = "The message or description attached to a payment. " +
|
||||||
|
"For example, \"coffee\" or \"rent March\".",
|
||||||
|
searchInfoInvoiceTitle = "Invoice",
|
||||||
|
searchInfoInvoiceDesc = "The payment request you scanned or pasted to send a payment. " +
|
||||||
|
"It usually starts with \"lnbc…\". " +
|
||||||
|
"You can paste just the first or last few characters.",
|
||||||
|
searchInfoHashTitle = "Hash",
|
||||||
|
searchInfoHashDesc = "A unique code that identifies this payment on the " +
|
||||||
|
"Lightning Network. Useful if someone asks you to " +
|
||||||
|
"confirm a specific transaction.",
|
||||||
|
searchInfoPreimageTitle = "Preimage",
|
||||||
|
searchInfoPreimageDesc = "A secret code that proves a payment was received. " +
|
||||||
|
"Only available after a payment completes successfully.",
|
||||||
|
searchInfoNodeAliasTitle = "Node alias",
|
||||||
|
searchInfoNodeAliasDesc = "The name of the Lightning node you sent a payment to. " +
|
||||||
|
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
|
||||||
|
"Aliases are resolved automatically for outgoing payments.",
|
||||||
|
searchInfoNodeIdTitle = "Node ID (pubkey)",
|
||||||
|
searchInfoNodeIdDesc = "The public key of the destination node. " +
|
||||||
|
"A 66-character hex string — you can paste just the first few characters.",
|
||||||
|
searchInfoFooter = "You can paste a full value or search with just a few characters — " +
|
||||||
|
"partial matches work too.",
|
||||||
|
|
||||||
|
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
||||||
|
statusSuccess = "Success", // e.g. "Success"
|
||||||
|
statusPending = "Pending", // e.g. "Pending"
|
||||||
|
statusFailed = "Failed", // e.g. "Failed"
|
||||||
|
statusUnknown = "Unknown", // e.g. "Unknown"
|
||||||
|
detailOutgoingTitle = "Outgoing payment",
|
||||||
|
detailIncomingTitle = "Incoming payment",
|
||||||
|
detailBack = "Back",
|
||||||
|
detailCouldNotLoad = "Could not load full details",
|
||||||
|
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
||||||
|
"Fee: $feeSat sats$fiatPart$ppmPart"
|
||||||
|
},
|
||||||
|
detailSectionDetails = "Details",
|
||||||
|
detailRowDate = "Date",
|
||||||
|
detailRowMemo = "Memo",
|
||||||
|
detailRowMemoEmpty = "—",
|
||||||
|
detailRowType = "Type",
|
||||||
|
detailRowComment = "Comment",
|
||||||
|
detailSectionSuccessAction = "Success Action",
|
||||||
|
detailSuccessMessage = "Message",
|
||||||
|
detailSuccessUrl = "URL",
|
||||||
|
detailSuccessDescription = "Description",
|
||||||
|
detailSectionTechnical = "Technical",
|
||||||
|
detailRowPaymentHash = "Hash",
|
||||||
|
detailRowPreimage = "Preimage",
|
||||||
|
detailRowDestination = "Destination",
|
||||||
|
detailCopyNodeId = "Copy node ID",
|
||||||
|
detailRowBolt11 = "BOLT11",
|
||||||
|
|
||||||
|
// ── PaymentRow ────────────────────────────────────────────────────────────
|
||||||
|
paymentRowSentCD = "Sent",
|
||||||
|
paymentRowReceivedCD = "Received",
|
||||||
|
paymentRowNoMemo = "No memo",
|
||||||
|
paymentRowPending = "Pending",
|
||||||
|
paymentRowFailed = "Failed",
|
||||||
|
),
|
||||||
|
|
||||||
|
nwc = NwcStrings(
|
||||||
|
back = "Back",
|
||||||
|
next = "Next",
|
||||||
|
done = "Done",
|
||||||
|
cancel = "Cancel",
|
||||||
|
schedule = "Schedule",
|
||||||
|
|
||||||
|
// ── Connection detail ─────────────────────────────────────────────────────
|
||||||
|
connectionDetailTitle = "Connection",
|
||||||
|
connectionUnknownWallet = "Unknown app",
|
||||||
|
connectionSectionDetails = "Details",
|
||||||
|
connectionRowCreated = "Connected",
|
||||||
|
connectionRowLastUsed = "Last used",
|
||||||
|
connectionRowExpires = "Expires",
|
||||||
|
connectionRowPubkey = "Public key",
|
||||||
|
connectionNeverUsed = "Never",
|
||||||
|
lastUsedToday = "Last used today",
|
||||||
|
lastUsedYesterday = "Last used yesterday",
|
||||||
|
lastUsedDate = "Last used %s",
|
||||||
|
connectionNoExpiry = "Never",
|
||||||
|
connectionExpired = { date -> "Expired · $date" },
|
||||||
|
connectionSectionPermissions = "Permissions",
|
||||||
|
connectionSectionBudgets = "Spending budgets",
|
||||||
|
connectionBudgetTitle = { window -> "$window budget" },
|
||||||
|
connectionBudgetUsed = { sats -> "Used: $sats sats" },
|
||||||
|
connectionBudgetTotal = { sats -> "of $sats sats" },
|
||||||
|
connectionRevoke = "Revoke connection",
|
||||||
|
connectionStatusActive = "Active",
|
||||||
|
connectionStatusExpired = "Expired",
|
||||||
|
|
||||||
|
// ── NWC permissions ───────────────────────────────────────────────────────
|
||||||
|
permPayInvoice = "Pay invoices",
|
||||||
|
permGetBalance = "Read balance",
|
||||||
|
permGetInfo = "Read wallet info",
|
||||||
|
permMakeInvoice = "Create invoices",
|
||||||
|
permLookupInvoice = "Look up invoices",
|
||||||
|
permListTransactions = "View transaction history",
|
||||||
|
permSignMessage = "Sign messages",
|
||||||
|
permPay = "Send payments",
|
||||||
|
permInvoice = "Create invoices",
|
||||||
|
permLookup = "Lookup invoice status",
|
||||||
|
permHistory = "Read transaction history",
|
||||||
|
permBalance = "Read wallet balance",
|
||||||
|
permInfo = "Read account info",
|
||||||
|
permLookupHistory = "Lookup status & transaction history",
|
||||||
|
permBalanceInfo = "Read balance & account info",
|
||||||
|
permSendPayments = "Send payments",
|
||||||
|
permCreateInvoices = "Create invoices",
|
||||||
|
permLookupInvoiceStatus = "Lookup status of invoice",
|
||||||
|
permReadTransactionHistory = "Read transaction history",
|
||||||
|
permReadWalletBalance = "Read wallet balance",
|
||||||
|
permReadAccountInfo = "Read account info",
|
||||||
|
revokeDialogTitle = "Revoke connection?",
|
||||||
|
revokeDialogBody = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
|
||||||
|
revokeDialogConfirm = "Revoke",
|
||||||
|
revokeDialogDismiss = "Cancel",
|
||||||
|
refreshDaily = "Daily",
|
||||||
|
refreshWeekly = "Weekly",
|
||||||
|
refreshMonthly = "Monthly",
|
||||||
|
refreshYearly = "Yearly",
|
||||||
|
refreshNever = "Never",
|
||||||
|
|
||||||
|
newConnection = "New connection",
|
||||||
|
labelHint = "Label (e.g. Amethyst, Bitrefill)",
|
||||||
|
expires = "Expires",
|
||||||
|
pickDate = "Pick date",
|
||||||
|
pickTime = "Pick time",
|
||||||
|
never = "Never",
|
||||||
|
budgets = "Budgets",
|
||||||
|
addBudget = "Add budget",
|
||||||
|
removeBudget = "Remove budget",
|
||||||
|
permissions = "Permissions",
|
||||||
|
unnamed = "Unnamed",
|
||||||
|
creating = "Creating…",
|
||||||
|
createConnection = "Create connection",
|
||||||
|
ok = "OK",
|
||||||
|
sats = "Sats",
|
||||||
|
resets = "Resets",
|
||||||
|
|
||||||
|
deleteConnection = "Delete connection",
|
||||||
|
unnamedConnection = "Unnamed connection",
|
||||||
|
neverUsed = "Never used",
|
||||||
|
expired = "Expired",
|
||||||
|
|
||||||
|
connectionCreated = "Connection created",
|
||||||
|
scanOrCopyHint = "Scan or copy this connection string into your app. It will not be shown again.",
|
||||||
|
nwcPairingQrCode = "NWC pairing QR code",
|
||||||
|
nwcConnectionString = "NWC connection string",
|
||||||
|
shareNwcConnectionString = "Share NWC connection string",
|
||||||
|
openInApp = "Open in app",
|
||||||
|
|
||||||
|
walletConnect = "Wallet Connect",
|
||||||
|
addConnection = "Add connection",
|
||||||
|
noCompatibleAppFound = "No compatible app found",
|
||||||
|
removeConnection = "Remove connection?",
|
||||||
|
removeConnectionDetail = "This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.",
|
||||||
|
remove = "Remove",
|
||||||
|
|
||||||
|
noConnectionsYet = "No connections yet",
|
||||||
|
tapToAddHint = "Tap + to connect an app via Nostr Wallet Connect.",
|
||||||
|
somethingWentWrong = "Something went wrong",
|
||||||
|
retry = "Retry",
|
||||||
|
|
||||||
|
failedToLoadConnections = "Failed to load connections",
|
||||||
|
failedToCreateConnection = "Failed to create connection",
|
||||||
|
failedToDeleteConnection = "Failed to delete connection",
|
||||||
|
failedToLoadConnection = "Failed to load connection",
|
||||||
|
),
|
||||||
|
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "App Lock",
|
||||||
|
appLockDescription = "Require biometric authentication (fingerprint, face, or PIN) every time you open Gudari Wallet.",
|
||||||
|
appLockToggleLabel = "Lock app on open",
|
||||||
|
appLockToggleEnabled = "Enabled (recommended)",
|
||||||
|
appLockToggleDisabled = "Disabled",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Authenticate to continue",
|
||||||
|
unlockButton = "Unlock",
|
||||||
|
biometricPromptTitle = "Unlock Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Authenticate to access your wallet",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,61 +54,8 @@ val EsHomeStrings = AppStrings(
|
|||||||
details = "Detalles",
|
details = "Detalles",
|
||||||
enterValidAmount = "Introduce una cantidad válida",
|
enterValidAmount = "Introduce una cantidad válida",
|
||||||
satsUnit = { sats -> "$sats sats" },
|
satsUnit = { sats -> "$sats sats" },
|
||||||
|
today = "hoy",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "ayer",
|
||||||
receive = "Recibir",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Error",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Código QR de dirección Lightning",
|
|
||||||
lightningAddressLabel = "Dirección Lightning",
|
|
||||||
shareLightningAddress = "Compartir dirección Lightning",
|
|
||||||
lightningInvoiceQrCode = "Código QR de factura Lightning",
|
|
||||||
invoiceLabel = "Factura",
|
|
||||||
shareInvoice = "Compartir factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Cantidad (sats)",
|
|
||||||
satsRange = { min, max -> "$min – $max sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Nota (opcional)",
|
|
||||||
memoPlaceholder = "¿Para qué es esto?",
|
|
||||||
creatingInvoice = "Creando factura…",
|
|
||||||
createInvoice = "Crear factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Esperando…",
|
|
||||||
invoiceExpired = "Factura expirada",
|
|
||||||
newInvoice = "Nueva factura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Retirar de",
|
|
||||||
minimumSats = { min -> "Mínimo es $min sats" },
|
|
||||||
maximumSats = { max -> "Máximo es $max sats" },
|
|
||||||
withdrawButton = "Retirar",
|
|
||||||
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "¡Pago recibido!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Reducir brillo",
|
|
||||||
brightnessBoost = "Aumentar brillo",
|
|
||||||
qrShare = "Compartir",
|
|
||||||
qrCopy = "Copiar",
|
|
||||||
qrNfcStop = "Detener NFC",
|
|
||||||
qrNfcStart = "Compartir por NFC",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
|
|
||||||
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
|
|
||||||
receiveVmWithdrawFailed = "Error al retirar",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Enviar",
|
send = "Enviar",
|
||||||
@@ -195,119 +142,6 @@ val EsHomeStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
|
sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
|
sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección",
|
||||||
|
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
|
||||||
historyTitle = "Historial",
|
|
||||||
historyClose = "Cerrar",
|
|
||||||
historySearchPayments = "Buscar pagos",
|
|
||||||
historyFilterPayments = "Filtrar pagos",
|
|
||||||
historyRetry = "Reintentar",
|
|
||||||
historyNoPayments = "Aún no hay pagos.",
|
|
||||||
historySearchPlaceholder = "Buscar transacciones",
|
|
||||||
historySearchHelp = "Ayuda de búsqueda",
|
|
||||||
historySearchPaste = "Pegar",
|
|
||||||
historySearchClear = "Limpiar búsqueda",
|
|
||||||
historySearchChip = { tail -> "búsqueda: …$tail" },
|
|
||||||
historyClearSearch = "Limpiar búsqueda",
|
|
||||||
filterDirectionOutgoing = "Salientes",
|
|
||||||
filterDirectionIncoming = "Entrantes",
|
|
||||||
filterClearDirection = "Quitar filtro de dirección",
|
|
||||||
filterStatusCompleted = "Completado",
|
|
||||||
filterStatusPending = "Pendiente",
|
|
||||||
filterStatusFailed = "Fallido",
|
|
||||||
filterRemoveStatus = "Quitar filtro de estado",
|
|
||||||
filterRemoveType = "Quitar filtro de tipo",
|
|
||||||
filterAmountRange = { min, max -> "$min–$max sats" },
|
|
||||||
filterAmountMin = { min -> "≥ $min sats" },
|
|
||||||
filterAmountMax = { max -> "≤ $max sats" },
|
|
||||||
filterClearAmount = "Quitar filtro de cantidad",
|
|
||||||
filterDateThisWeek = "Esta semana",
|
|
||||||
filterDateThisMonth = "Este mes",
|
|
||||||
filterDateThisYear = "Este año",
|
|
||||||
filterDateRange = { from, to -> "$from–$to" },
|
|
||||||
filterDateFrom = { from -> "Desde $from" },
|
|
||||||
filterDateUntil = { until -> "Hasta $until" },
|
|
||||||
filterClearDate = "Quitar filtro de fecha",
|
|
||||||
|
|
||||||
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
|
||||||
filterSheetTitle = "Filtrar pagos",
|
|
||||||
filterSectionDirection = "Dirección",
|
|
||||||
filterDirectionAll = "Todos",
|
|
||||||
filterSectionStatus = "Estado",
|
|
||||||
filterSectionType = "Tipo",
|
|
||||||
filterSectionAmount = "Cantidad (sats)",
|
|
||||||
filterAmountMin_label = "Mín.",
|
|
||||||
filterAmountMax_label = "Máx.",
|
|
||||||
filterAmountSuffix = "sats",
|
|
||||||
filterSectionDate = "Fecha",
|
|
||||||
filterDateFrom_label = "Desde",
|
|
||||||
filterDateTo_label = "Hasta",
|
|
||||||
filterDialogOk = "Aceptar",
|
|
||||||
filterDialogCancel = "Cancelar",
|
|
||||||
|
|
||||||
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
|
||||||
searchInfoTitle = "¿Qué puedo buscar?",
|
|
||||||
searchInfoSubtitle = "Escribe cualquier parte de lo siguiente para encontrar un pago:",
|
|
||||||
searchInfoMemoTitle = "Nota",
|
|
||||||
searchInfoMemoDesc = "El mensaje o descripción adjunto a un pago. " +
|
|
||||||
"Por ejemplo, \"café\" o \"alquiler marzo\".",
|
|
||||||
searchInfoInvoiceTitle = "Factura",
|
|
||||||
searchInfoInvoiceDesc = "La solicitud de pago que escaneaste o pegaste para enviar un pago. " +
|
|
||||||
"Normalmente empieza por \"lnbc…\". " +
|
|
||||||
"Puedes pegar solo los primeros o últimos caracteres.",
|
|
||||||
searchInfoHashTitle = "Hash",
|
|
||||||
searchInfoHashDesc = "Un código único que identifica este pago en la " +
|
|
||||||
"red Lightning. Útil si alguien te pide que " +
|
|
||||||
"confirmes una transacción específica.",
|
|
||||||
searchInfoPreimageTitle = "Preimagen",
|
|
||||||
searchInfoPreimageDesc = "Un código secreto que demuestra que se recibió un pago. " +
|
|
||||||
"Solo disponible después de que un pago se complete correctamente.",
|
|
||||||
searchInfoNodeAliasTitle = "Alias del nodo",
|
|
||||||
searchInfoNodeAliasDesc = "El nombre del nodo Lightning al que enviaste un pago. " +
|
|
||||||
"Por ejemplo, \"ACINQ\" o \"Wallet of Satoshi\". " +
|
|
||||||
"Los alias se resuelven automáticamente para pagos salientes.",
|
|
||||||
searchInfoNodeIdTitle = "ID del nodo (clave pública)",
|
|
||||||
searchInfoNodeIdDesc = "La clave pública del nodo de destino. " +
|
|
||||||
"Una cadena hexadecimal de 66 caracteres — puedes pegar solo los primeros.",
|
|
||||||
searchInfoFooter = "Puedes pegar un valor completo o buscar con solo unos pocos caracteres — " +
|
|
||||||
"las coincidencias parciales también funcionan.",
|
|
||||||
|
|
||||||
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
|
||||||
statusSuccess = "Completado",
|
|
||||||
statusPending = "Pendiente",
|
|
||||||
statusFailed = "Fallido",
|
|
||||||
statusUnknown = "Desconocido",
|
|
||||||
detailOutgoingTitle = "Pago saliente",
|
|
||||||
detailIncomingTitle = "Pago entrante",
|
|
||||||
detailBack = "Atrás",
|
|
||||||
detailCouldNotLoad = "No se pudieron cargar los detalles completos",
|
|
||||||
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
|
||||||
"Comisión: $feeSat sats$fiatPart$ppmPart"
|
|
||||||
},
|
|
||||||
detailSectionDetails = "Detalles",
|
|
||||||
detailRowDate = "Fecha",
|
|
||||||
detailRowMemo = "Nota",
|
|
||||||
detailRowMemoEmpty = "—",
|
|
||||||
detailRowType = "Tipo",
|
|
||||||
detailRowComment = "Comentario",
|
|
||||||
detailSectionSuccessAction = "Acción de éxito",
|
|
||||||
detailSuccessMessage = "Mensaje",
|
|
||||||
detailSuccessUrl = "URL",
|
|
||||||
detailSuccessDescription = "Descripción",
|
|
||||||
detailSectionTechnical = "Técnico",
|
|
||||||
detailRowPaymentHash = "Hash",
|
|
||||||
detailRowPreimage = "Preimagen",
|
|
||||||
detailRowDestination = "Destino",
|
|
||||||
detailCopyNodeId = "Copiar ID del nodo",
|
|
||||||
detailRowBolt11 = "BOLT11",
|
|
||||||
|
|
||||||
// ── PaymentRow ────────────────────────────────────────────────────────────
|
|
||||||
paymentRowSentCD = "Enviado",
|
|
||||||
paymentRowReceivedCD = "Recibido",
|
|
||||||
paymentRowNoMemo = "Sin nota",
|
|
||||||
paymentRowPending = "Pendiente",
|
|
||||||
paymentRowFailed = "Fallido",
|
|
||||||
|
|
||||||
|
|
||||||
onboarding = OnboardingStrings(
|
onboarding = OnboardingStrings(
|
||||||
back = "Atrás",
|
back = "Atrás",
|
||||||
next = "Siguiente",
|
next = "Siguiente",
|
||||||
@@ -325,6 +159,7 @@ val EsHomeStrings = AppStrings(
|
|||||||
hideAdminKey = "Ocultar",
|
hideAdminKey = "Ocultar",
|
||||||
showAdminKey = "Mostrar",
|
showAdminKey = "Mostrar",
|
||||||
fieldsRequired = "Todos los campos son obligatorios.",
|
fieldsRequired = "Todos los campos son obligatorios.",
|
||||||
|
invalidKeyFormat = "Formato de clave no válido",
|
||||||
connect = "Conectar",
|
connect = "Conectar",
|
||||||
notificationsTitle = "Mantente al día",
|
notificationsTitle = "Mantente al día",
|
||||||
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
|
notificationsSubtitle = "Recibe notificaciones al instante cuando recibas un pago.",
|
||||||
@@ -352,5 +187,300 @@ val EsHomeStrings = AppStrings(
|
|||||||
scheduleSync = "Programar sincronización",
|
scheduleSync = "Programar sincronización",
|
||||||
schedule = "Programar",
|
schedule = "Programar",
|
||||||
cancel = "Cancelar"
|
cancel = "Cancelar"
|
||||||
)
|
),
|
||||||
|
|
||||||
|
receive = ReceiveStrings(
|
||||||
|
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
||||||
|
receive = "Recibir",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
||||||
|
receiveError = "Error",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
||||||
|
lightningAddressQrCode = "Código QR de dirección Lightning",
|
||||||
|
lightningAddressLabel = "Dirección Lightning",
|
||||||
|
shareLightningAddress = "Compartir dirección Lightning",
|
||||||
|
lightningInvoiceQrCode = "Código QR de factura Lightning",
|
||||||
|
invoiceLabel = "Factura",
|
||||||
|
shareInvoice = "Compartir factura",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
||||||
|
amountInSats = "Cantidad (sats)",
|
||||||
|
satsRange = { min, max -> "$min – $max sats" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
||||||
|
memoOptional = "Nota (opcional)",
|
||||||
|
memoPlaceholder = "¿Para qué es esto?",
|
||||||
|
creatingInvoice = "Creando factura…",
|
||||||
|
createInvoice = "Crear factura",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
||||||
|
waiting = "Esperando…",
|
||||||
|
invoiceExpired = "Factura expirada",
|
||||||
|
newInvoice = "Nueva factura",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
||||||
|
withdrawFrom = "Retirar de",
|
||||||
|
minimumSats = { min -> "Mínimo es $min sats" },
|
||||||
|
maximumSats = { max -> "Máximo es $max sats" },
|
||||||
|
withdrawButton = "Retirar",
|
||||||
|
withdrawButtonAmount = { amount -> "Retirar $amount sats" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
||||||
|
paymentReceived = "¡Pago recibido!",
|
||||||
|
|
||||||
|
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
||||||
|
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
|
||||||
|
|
||||||
|
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
||||||
|
brightnessReduce = "Reducir brillo",
|
||||||
|
brightnessBoost = "Aumentar brillo",
|
||||||
|
qrShare = "Compartir",
|
||||||
|
qrCopy = "Copiar",
|
||||||
|
qrNfcStop = "Detener NFC",
|
||||||
|
qrNfcStart = "Compartir por NFC",
|
||||||
|
openInApp = "Abrir en app",
|
||||||
|
noAppFound = "No hay aplicación compatible",
|
||||||
|
|
||||||
|
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
||||||
|
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
|
||||||
|
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
|
||||||
|
receiveVmWithdrawFailed = "Error al retirar",
|
||||||
|
),
|
||||||
|
|
||||||
|
history = HistoryStrings(
|
||||||
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
|
historyTitle = "Historial",
|
||||||
|
historyClose = "Cerrar",
|
||||||
|
historySearchPayments = "Buscar pagos",
|
||||||
|
historyFilterPayments = "Filtrar pagos",
|
||||||
|
historyRetry = "Reintentar",
|
||||||
|
historyNoPayments = "Aún no hay pagos.",
|
||||||
|
historySearchPlaceholder = "Buscar transacciones",
|
||||||
|
historySearchHelp = "Ayuda de búsqueda",
|
||||||
|
historySearchPaste = "Pegar",
|
||||||
|
historySearchClear = "Limpiar búsqueda",
|
||||||
|
historySearchChip = { tail -> "búsqueda: …$tail" },
|
||||||
|
historyClearSearch = "Limpiar búsqueda",
|
||||||
|
filterDirectionOutgoing = "Salientes",
|
||||||
|
filterDirectionIncoming = "Entrantes",
|
||||||
|
filterClearDirection = "Quitar filtro de dirección",
|
||||||
|
filterStatusCompleted = "Completado",
|
||||||
|
filterStatusPending = "Pendiente",
|
||||||
|
filterStatusFailed = "Fallido",
|
||||||
|
filterRemoveStatus = "Quitar filtro de estado",
|
||||||
|
filterRemoveType = "Quitar filtro de tipo",
|
||||||
|
filterAmountRange = { min, max -> "$min–$max sats" },
|
||||||
|
filterAmountMin = { min -> "≥ $min sats" },
|
||||||
|
filterAmountMax = { max -> "≤ $max sats" },
|
||||||
|
filterClearAmount = "Quitar filtro de cantidad",
|
||||||
|
filterDateThisWeek = "Esta semana",
|
||||||
|
filterDateThisMonth = "Este mes",
|
||||||
|
filterDateThisYear = "Este año",
|
||||||
|
filterDateRange = { from, to -> "$from–$to" },
|
||||||
|
filterDateFrom = { from -> "Desde $from" },
|
||||||
|
filterDateUntil = { until -> "Hasta $until" },
|
||||||
|
filterClearDate = "Quitar filtro de fecha",
|
||||||
|
|
||||||
|
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
||||||
|
filterSheetTitle = "Filtrar pagos",
|
||||||
|
filterSectionDirection = "Dirección",
|
||||||
|
filterDirectionAll = "Todos",
|
||||||
|
filterSectionStatus = "Estado",
|
||||||
|
filterSectionType = "Tipo",
|
||||||
|
filterSectionAmount = "Cantidad (sats)",
|
||||||
|
filterAmountMin_label = "Mín.",
|
||||||
|
filterAmountMax_label = "Máx.",
|
||||||
|
filterAmountSuffix = "sats",
|
||||||
|
filterSectionDate = "Fecha",
|
||||||
|
filterDateFrom_label = "Desde",
|
||||||
|
filterDateTo_label = "Hasta",
|
||||||
|
filterDialogOk = "Aceptar",
|
||||||
|
filterDialogCancel = "Cancelar",
|
||||||
|
|
||||||
|
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
||||||
|
searchInfoTitle = "¿Qué puedo buscar?",
|
||||||
|
searchInfoSubtitle = "Escribe cualquier parte de lo siguiente para encontrar un pago:",
|
||||||
|
searchInfoMemoTitle = "Nota",
|
||||||
|
searchInfoMemoDesc = "El mensaje o descripción adjunto a un pago. " +
|
||||||
|
"Por ejemplo, \"café\" o \"alquiler marzo\".",
|
||||||
|
searchInfoInvoiceTitle = "Factura",
|
||||||
|
searchInfoInvoiceDesc = "La solicitud de pago que escaneaste o pegaste para enviar un pago. " +
|
||||||
|
"Normalmente empieza por \"lnbc…\". " +
|
||||||
|
"Puedes pegar solo los primeros o últimos caracteres.",
|
||||||
|
searchInfoHashTitle = "Hash",
|
||||||
|
searchInfoHashDesc = "Un código único que identifica este pago en la " +
|
||||||
|
"red Lightning. Útil si alguien te pide que " +
|
||||||
|
"confirmes una transacción específica.",
|
||||||
|
searchInfoPreimageTitle = "Preimagen",
|
||||||
|
searchInfoPreimageDesc = "Un código secreto que demuestra que se recibió un pago. " +
|
||||||
|
"Solo disponible después de que un pago se complete correctamente.",
|
||||||
|
searchInfoNodeAliasTitle = "Alias del nodo",
|
||||||
|
searchInfoNodeAliasDesc = "El nombre del nodo Lightning al que enviaste un pago. " +
|
||||||
|
"Por ejemplo, \"ACINQ\" o \"Wallet of Satoshi\". " +
|
||||||
|
"Los alias se resuelven automáticamente para pagos salientes.",
|
||||||
|
searchInfoNodeIdTitle = "ID del nodo (clave pública)",
|
||||||
|
searchInfoNodeIdDesc = "La clave pública del nodo de destino. " +
|
||||||
|
"Una cadena hexadecimal de 66 caracteres — puedes pegar solo los primeros.",
|
||||||
|
searchInfoFooter = "Puedes pegar un valor completo o buscar con solo unos pocos caracteres — " +
|
||||||
|
"las coincidencias parciales también funcionan.",
|
||||||
|
|
||||||
|
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
||||||
|
statusSuccess = "Completado",
|
||||||
|
statusPending = "Pendiente",
|
||||||
|
statusFailed = "Fallido",
|
||||||
|
statusUnknown = "Desconocido",
|
||||||
|
detailOutgoingTitle = "Pago saliente",
|
||||||
|
detailIncomingTitle = "Pago entrante",
|
||||||
|
detailBack = "Atrás",
|
||||||
|
detailCouldNotLoad = "No se pudieron cargar los detalles completos",
|
||||||
|
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
||||||
|
"Comisión: $feeSat sats$fiatPart$ppmPart"
|
||||||
|
},
|
||||||
|
detailSectionDetails = "Detalles",
|
||||||
|
detailRowDate = "Fecha",
|
||||||
|
detailRowMemo = "Nota",
|
||||||
|
detailRowMemoEmpty = "—",
|
||||||
|
detailRowType = "Tipo",
|
||||||
|
detailRowComment = "Comentario",
|
||||||
|
detailSectionSuccessAction = "Acción de éxito",
|
||||||
|
detailSuccessMessage = "Mensaje",
|
||||||
|
detailSuccessUrl = "URL",
|
||||||
|
detailSuccessDescription = "Descripción",
|
||||||
|
detailSectionTechnical = "Técnico",
|
||||||
|
detailRowPaymentHash = "Hash",
|
||||||
|
detailRowPreimage = "Preimagen",
|
||||||
|
detailRowDestination = "Destino",
|
||||||
|
detailCopyNodeId = "Copiar ID del nodo",
|
||||||
|
detailRowBolt11 = "BOLT11",
|
||||||
|
|
||||||
|
// ── PaymentRow ────────────────────────────────────────────────────────────
|
||||||
|
paymentRowSentCD = "Enviado",
|
||||||
|
paymentRowReceivedCD = "Recibido",
|
||||||
|
paymentRowNoMemo = "Sin nota",
|
||||||
|
paymentRowPending = "Pendiente",
|
||||||
|
paymentRowFailed = "Fallido",
|
||||||
|
),
|
||||||
|
|
||||||
|
nwc = NwcStrings(
|
||||||
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
|
back = "Atrás",
|
||||||
|
next = "Siguiente",
|
||||||
|
done = "Listo",
|
||||||
|
cancel = "Cancelar",
|
||||||
|
schedule = "Programar",
|
||||||
|
|
||||||
|
// ── Connection detail ─────────────────────────────────────────────────────
|
||||||
|
connectionDetailTitle = "Conexión",
|
||||||
|
connectionUnknownWallet = "App desconocida",
|
||||||
|
connectionSectionDetails = "Detalles",
|
||||||
|
connectionRowCreated = "Conectado",
|
||||||
|
connectionRowLastUsed = "Último uso",
|
||||||
|
connectionRowExpires = "Vence",
|
||||||
|
connectionRowPubkey = "Clave pública",
|
||||||
|
connectionNeverUsed = "Nunca",
|
||||||
|
lastUsedToday = "Usada hoy",
|
||||||
|
lastUsedYesterday = "Usada ayer",
|
||||||
|
lastUsedDate = "Usada %s",
|
||||||
|
connectionNoExpiry = "Sin vencimiento",
|
||||||
|
connectionExpired = { date -> "Vencido · $date" },
|
||||||
|
connectionSectionPermissions = "Permisos",
|
||||||
|
connectionSectionBudgets = "Presupuestos de gasto",
|
||||||
|
connectionBudgetTitle = { window -> "Presupuesto $window" },
|
||||||
|
connectionBudgetUsed = { sats -> "Usado: $sats sats" },
|
||||||
|
connectionBudgetTotal = { sats -> "de $sats sats" },
|
||||||
|
connectionRevoke = "Revocar conexión",
|
||||||
|
connectionStatusActive = "Activa",
|
||||||
|
connectionStatusExpired = "Vencida",
|
||||||
|
|
||||||
|
// ── NWC permissions ───────────────────────────────────────────────────────
|
||||||
|
permPayInvoice = "Pagar facturas",
|
||||||
|
permGetBalance = "Ver saldo",
|
||||||
|
permGetInfo = "Ver información del monedero",
|
||||||
|
permMakeInvoice = "Crear facturas",
|
||||||
|
permLookupInvoice = "Consultar facturas",
|
||||||
|
permListTransactions = "Ver historial de transacciones",
|
||||||
|
permSignMessage = "Firmar mensajes",
|
||||||
|
permPay = "Enviar pagos",
|
||||||
|
permInvoice = "Crear facturas",
|
||||||
|
permLookup = "Consultar estado de facturas",
|
||||||
|
permHistory = "Leer historial de transacciones",
|
||||||
|
permBalance = "Leer saldo del monedero",
|
||||||
|
permInfo = "Leer información de cuenta",
|
||||||
|
permLookupHistory = "Consultar estado e historial de transacciones",
|
||||||
|
permBalanceInfo = "Ver saldo e información de cuenta",
|
||||||
|
permSendPayments = "Enviar pagos",
|
||||||
|
permCreateInvoices = "Crear facturas",
|
||||||
|
permLookupInvoiceStatus = "Consultar estado de factura",
|
||||||
|
permReadTransactionHistory = "Leer historial de transacciones",
|
||||||
|
permReadWalletBalance = "Leer saldo del monedero",
|
||||||
|
permReadAccountInfo = "Leer información de cuenta",
|
||||||
|
revokeDialogTitle = "¿Revocar conexión?",
|
||||||
|
revokeDialogBody = "Esto revocará permanentemente esta clave Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
|
||||||
|
revokeDialogConfirm = "Revocar",
|
||||||
|
revokeDialogDismiss = "Cancelar",
|
||||||
|
refreshDaily = "Diario",
|
||||||
|
refreshWeekly = "Semanal",
|
||||||
|
refreshMonthly = "Mensual",
|
||||||
|
refreshYearly = "Anual",
|
||||||
|
refreshNever = "Nunca",
|
||||||
|
|
||||||
|
newConnection = "Nueva conexión",
|
||||||
|
labelHint = "Etiqueta (p.ej. Amethyst, Bitrefill)",
|
||||||
|
expires = "Caduca",
|
||||||
|
pickDate = "Elegir fecha",
|
||||||
|
pickTime = "Elegir hora",
|
||||||
|
never = "Nunca",
|
||||||
|
budgets = "Presupuestos",
|
||||||
|
addBudget = "Añadir presupuesto",
|
||||||
|
removeBudget = "Eliminar presupuesto",
|
||||||
|
permissions = "Permisos",
|
||||||
|
unnamed = "Sin nombre",
|
||||||
|
creating = "Creando…",
|
||||||
|
createConnection = "Crear conexión",
|
||||||
|
ok = "OK",
|
||||||
|
sats = "Sats",
|
||||||
|
resets = "Se reinicia",
|
||||||
|
|
||||||
|
deleteConnection = "Eliminar conexión",
|
||||||
|
unnamedConnection = "Conexión sin nombre",
|
||||||
|
neverUsed = "Nunca usada",
|
||||||
|
expired = "Caducada",
|
||||||
|
|
||||||
|
connectionCreated = "Conexión creada",
|
||||||
|
scanOrCopyHint = "Escanea o copia esta cadena de conexión en tu app. No se volverá a mostrar.",
|
||||||
|
nwcPairingQrCode = "Código QR de emparejamiento NWC",
|
||||||
|
nwcConnectionString = "Cadena de conexión NWC",
|
||||||
|
shareNwcConnectionString = "Compartir cadena de conexión NWC",
|
||||||
|
openInApp = "Abrir en app",
|
||||||
|
|
||||||
|
walletConnect = "Wallet Connect",
|
||||||
|
addConnection = "Añadir conexión",
|
||||||
|
noCompatibleAppFound = "No se encontró una app compatible",
|
||||||
|
removeConnection = "¿Eliminar conexión?",
|
||||||
|
removeConnectionDetail = "Esto revocará permanentemente esta clave de Nostr Wallet Connect. Cualquier app que la use perderá el acceso.",
|
||||||
|
remove = "Eliminar",
|
||||||
|
|
||||||
|
noConnectionsYet = "Aún no hay conexiones",
|
||||||
|
tapToAddHint = "Toca + para conectar una app vía Nostr Wallet Connect.",
|
||||||
|
somethingWentWrong = "Algo salió mal",
|
||||||
|
retry = "Reintentar",
|
||||||
|
|
||||||
|
failedToLoadConnections = "Error al cargar las conexiones",
|
||||||
|
failedToCreateConnection = "Error al crear la conexión",
|
||||||
|
failedToDeleteConnection = "Error al eliminar la conexión",
|
||||||
|
failedToLoadConnection = "Error al cargar la conexión",
|
||||||
|
),
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "Bloqueo de App",
|
||||||
|
appLockDescription = "Solicitar autenticación biométrica (huella, cara o PIN) cada vez que se abra Gudari Wallet.",
|
||||||
|
appLockToggleLabel = "Bloquear al abrir",
|
||||||
|
appLockToggleEnabled = "Activado (recomendado)",
|
||||||
|
appLockToggleDisabled = "Desactivado",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Autentícate para continuar",
|
||||||
|
unlockButton = "Desbloquear",
|
||||||
|
biometricPromptTitle = "Desbloquear Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Autentícate para acceder a tu cartera",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,61 +54,8 @@ val EuStrings = AppStrings(
|
|||||||
details = "Xehetasunak",
|
details = "Xehetasunak",
|
||||||
enterValidAmount = "Sartu zenbateko baliogarri bat",
|
enterValidAmount = "Sartu zenbateko baliogarri bat",
|
||||||
satsUnit = { sats -> "$sats sat" },
|
satsUnit = { sats -> "$sats sat" },
|
||||||
|
today = "gaur",
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
yesterday = "atzo",
|
||||||
receive = "Jaso",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
receiveError = "Errorea",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
lightningAddressQrCode = "Lightning helbidearen QR kodea",
|
|
||||||
lightningAddressLabel = "Lightning helbidea",
|
|
||||||
shareLightningAddress = "Partekatu Lightning helbidea",
|
|
||||||
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
|
|
||||||
invoiceLabel = "Faktura",
|
|
||||||
shareInvoice = "Partekatu faktura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
amountInSats = "Zenbatekoa (sat)",
|
|
||||||
satsRange = { min, max -> "$min – $max sat" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
memoOptional = "Oharra (aukerakoa)",
|
|
||||||
memoPlaceholder = "Zertarako da hau?",
|
|
||||||
creatingInvoice = "Faktura sortzen…",
|
|
||||||
createInvoice = "Sortu faktura",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
waiting = "Zain…",
|
|
||||||
invoiceExpired = "Faktura iraungita",
|
|
||||||
newInvoice = "Faktura berria",
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
withdrawFrom = "Atera hemendik:",
|
|
||||||
minimumSats = { min -> "Gutxienekoa $min sat da" },
|
|
||||||
maximumSats = { max -> "Gehienekoa $max sat da" },
|
|
||||||
withdrawButton = "Atera",
|
|
||||||
withdrawButtonAmount = { amount -> "Atera $amount sat" },
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
paymentReceived = "Ordainketa jasota!",
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
amountDisplaySats = { amount -> "$amount sat" },
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
brightnessReduce = "Murriztu distira",
|
|
||||||
brightnessBoost = "Handitu distira",
|
|
||||||
qrShare = "Partekatu",
|
|
||||||
qrCopy = "Kopiatu",
|
|
||||||
qrNfcStop = "Gelditu NFC partekatzea",
|
|
||||||
qrNfcStart = "Partekatu NFC bidez",
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
|
|
||||||
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
|
|
||||||
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
send = "Bidali",
|
send = "Bidali",
|
||||||
@@ -196,118 +143,6 @@ val EuStrings = AppStrings(
|
|||||||
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
|
sendVmUnsupportedType = { tag -> "Mota onartzen ez dena: $tag" },
|
||||||
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
|
sendVmPaymentFailedRescan = "Ordainketak huts egin du — ezin izan da helbidea berriro eskuratu",
|
||||||
|
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
|
||||||
historyTitle = "Historia",
|
|
||||||
historyClose = "Itxi",
|
|
||||||
historySearchPayments = "Bilatu ordainketak",
|
|
||||||
historyFilterPayments = "Iragazi ordainketak",
|
|
||||||
historyRetry = "Saiatu berriro",
|
|
||||||
historyNoPayments = "Oraindik ez dago ordainketarik.",
|
|
||||||
historySearchPlaceholder = "Bilatu transakzioak",
|
|
||||||
historySearchHelp = "Bilaketa-laguntza",
|
|
||||||
historySearchPaste = "Itsatsi",
|
|
||||||
historySearchClear = "Garbitu bilaketa",
|
|
||||||
historySearchChip = { tail -> "bilaketa: …$tail" },
|
|
||||||
historyClearSearch = "Garbitu bilaketa",
|
|
||||||
filterDirectionOutgoing = "Irteerako",
|
|
||||||
filterDirectionIncoming = "Sarrerako",
|
|
||||||
filterClearDirection = "Garbitu norabide-iragazkia",
|
|
||||||
filterStatusCompleted = "Osatuta",
|
|
||||||
filterStatusPending = "Zain",
|
|
||||||
filterStatusFailed = "Huts eginda",
|
|
||||||
filterRemoveStatus = "Kendu egoera-iragazkia",
|
|
||||||
filterRemoveType = "Kendu mota-iragazkia",
|
|
||||||
filterAmountRange = { min, max -> "$min–$max sat" },
|
|
||||||
filterAmountMin = { min -> "≥ $min sat" },
|
|
||||||
filterAmountMax = { max -> "≤ $max sat" },
|
|
||||||
filterClearAmount = "Garbitu zenbateko-iragazkia",
|
|
||||||
filterDateThisWeek = "Aste honetan",
|
|
||||||
filterDateThisMonth = "Hilabete honetan",
|
|
||||||
filterDateThisYear = "Urte honetan",
|
|
||||||
filterDateRange = { from, to -> "$from–$to" },
|
|
||||||
filterDateFrom = { from -> "$from-tik" },
|
|
||||||
filterDateUntil = { until -> "$until arte" },
|
|
||||||
filterClearDate = "Garbitu data-iragazkia",
|
|
||||||
|
|
||||||
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
|
||||||
filterSheetTitle = "Iragazi ordainketak",
|
|
||||||
filterSectionDirection = "Norabidea",
|
|
||||||
filterDirectionAll = "Guztiak",
|
|
||||||
filterSectionStatus = "Egoera",
|
|
||||||
filterSectionType = "Mota",
|
|
||||||
filterSectionAmount = "Zenbatekoa (sat)",
|
|
||||||
filterAmountMin_label = "Gutxienekoa",
|
|
||||||
filterAmountMax_label = "Gehienekoa",
|
|
||||||
filterAmountSuffix = "sat",
|
|
||||||
filterSectionDate = "Data",
|
|
||||||
filterDateFrom_label = "Nondik",
|
|
||||||
filterDateTo_label = "Nora arte",
|
|
||||||
filterDialogOk = "Ados",
|
|
||||||
filterDialogCancel = "Utzi",
|
|
||||||
|
|
||||||
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
|
||||||
searchInfoTitle = "Zer bila dezaket?",
|
|
||||||
searchInfoSubtitle = "Idatzi hurrengoetako edozein zati ordainketa bat aurkitzeko:",
|
|
||||||
searchInfoMemoTitle = "Oharra",
|
|
||||||
searchInfoMemoDesc = "Ordainketa bati erantsitako mezua edo deskribapena. " +
|
|
||||||
"Adibidez, \"kafea\" edo \"martxoko alokairua\".",
|
|
||||||
searchInfoInvoiceTitle = "Faktura",
|
|
||||||
searchInfoInvoiceDesc = "Ordainketa bat bidaltzeko eskaneatu edo itsatsi zenuen ordainketa-eskaera. " +
|
|
||||||
"Normalean \"lnbc…\" hasierarekin dator. " +
|
|
||||||
"Lehen edo azken karaktere batzuk bakarrik itsats ditzakezu.",
|
|
||||||
searchInfoHashTitle = "Hash",
|
|
||||||
searchInfoHashDesc = "Ordainketa hau Lightning Sarean identifikatzen duen kode bakarra. " +
|
|
||||||
"Erabilgarria norbaitek transakzio zehatz bat berresteko eskatzen dizunean.",
|
|
||||||
searchInfoPreimageTitle = "Aurre-irudia",
|
|
||||||
searchInfoPreimageDesc = "Ordainketa bat jaso dela frogatzen duen kode sekretua. " +
|
|
||||||
"Ordainketa arrakastaz osatu ondoren bakarrik dago erabilgarri.",
|
|
||||||
searchInfoNodeAliasTitle = "Nodo-izengoitia",
|
|
||||||
searchInfoNodeAliasDesc = "Ordainketa bidali zenion Lightning nodoaren izena. " +
|
|
||||||
"Adibidez, \"ACINQ\" edo \"Wallet of Satoshi\". " +
|
|
||||||
"Izengoitiak automatikoki ebazten dira irteerako ordainketetan.",
|
|
||||||
searchInfoNodeIdTitle = "Nodo IDa (gako publikoa)",
|
|
||||||
searchInfoNodeIdDesc = "Helmuga-nodoaren gako publikoa. " +
|
|
||||||
"66 karaktereko hex katea — lehen karaktere batzuk bakarrik itsats ditzakezu.",
|
|
||||||
searchInfoFooter = "Balio osoa itsatsi dezakezu edo karaktere gutxi batzuekin bilatu — " +
|
|
||||||
"zati-bat datozenak ere funtzionatzen dute.",
|
|
||||||
|
|
||||||
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
|
||||||
statusSuccess = "Osatuta",
|
|
||||||
statusPending = "Zain",
|
|
||||||
statusFailed = "Huts eginda",
|
|
||||||
statusUnknown = "Ezezaguna",
|
|
||||||
detailOutgoingTitle = "Irteerako ordainketa",
|
|
||||||
detailIncomingTitle = "Sarrerako ordainketa",
|
|
||||||
detailBack = "Atzera",
|
|
||||||
detailCouldNotLoad = "Ezin izan dira xehetasun osoak kargatu",
|
|
||||||
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
|
||||||
"Komisioa: $feeSat sat$fiatPart$ppmPart"
|
|
||||||
},
|
|
||||||
detailSectionDetails = "Xehetasunak",
|
|
||||||
detailRowDate = "Data",
|
|
||||||
detailRowMemo = "Oharra",
|
|
||||||
detailRowMemoEmpty = "—",
|
|
||||||
detailRowType = "Mota",
|
|
||||||
detailRowComment = "Iruzkina",
|
|
||||||
detailSectionSuccessAction = "Arrakasta-ekintza",
|
|
||||||
detailSuccessMessage = "Mezua",
|
|
||||||
detailSuccessUrl = "URLa",
|
|
||||||
detailSuccessDescription = "Deskribapena",
|
|
||||||
detailSectionTechnical = "Teknikoa",
|
|
||||||
detailRowPaymentHash = "Hash",
|
|
||||||
detailRowPreimage = "Aurre-irudia",
|
|
||||||
detailRowDestination = "Helmuga",
|
|
||||||
detailCopyNodeId = "Kopiatu nodo IDa",
|
|
||||||
detailRowBolt11 = "BOLT11",
|
|
||||||
|
|
||||||
// ── PaymentRow ────────────────────────────────────────────────────────────
|
|
||||||
paymentRowSentCD = "Bidalita",
|
|
||||||
paymentRowReceivedCD = "Jasota",
|
|
||||||
paymentRowNoMemo = "Oharrik gabe",
|
|
||||||
paymentRowPending = "Zain",
|
|
||||||
paymentRowFailed = "Huts eginda",
|
|
||||||
|
|
||||||
|
|
||||||
onboarding = OnboardingStrings(
|
onboarding = OnboardingStrings(
|
||||||
back = "Atzera",
|
back = "Atzera",
|
||||||
next = "Hurrengoa",
|
next = "Hurrengoa",
|
||||||
@@ -325,6 +160,7 @@ val EuStrings = AppStrings(
|
|||||||
hideAdminKey = "Ezkutatu",
|
hideAdminKey = "Ezkutatu",
|
||||||
showAdminKey = "Erakutsi",
|
showAdminKey = "Erakutsi",
|
||||||
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
|
fieldsRequired = "Eremu guztiak beharrezkoak dira.",
|
||||||
|
invalidKeyFormat = "Gako formatu baliogabea",
|
||||||
connect = "Konektatu",
|
connect = "Konektatu",
|
||||||
notificationsTitle = "Egon eguneratuta",
|
notificationsTitle = "Egon eguneratuta",
|
||||||
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
|
notificationsSubtitle = "Jaso jakinarazpenak berehala transakzio bat jasotzen duzunean.",
|
||||||
@@ -352,5 +188,299 @@ val EuStrings = AppStrings(
|
|||||||
scheduleSync = "Programatu sinkronizazioa",
|
scheduleSync = "Programatu sinkronizazioa",
|
||||||
schedule = "Programatu",
|
schedule = "Programatu",
|
||||||
cancel = "Utzi"
|
cancel = "Utzi"
|
||||||
|
),
|
||||||
|
|
||||||
|
receive = ReceiveStrings(
|
||||||
|
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
||||||
|
receive = "Jaso",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
||||||
|
receiveError = "Errorea",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
||||||
|
lightningAddressQrCode = "Lightning helbidearen QR kodea",
|
||||||
|
lightningAddressLabel = "Lightning helbidea",
|
||||||
|
shareLightningAddress = "Partekatu Lightning helbidea",
|
||||||
|
lightningInvoiceQrCode = "Lightning fakturaren QR kodea",
|
||||||
|
invoiceLabel = "Faktura",
|
||||||
|
shareInvoice = "Partekatu faktura",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
||||||
|
amountInSats = "Zenbatekoa (sat)",
|
||||||
|
satsRange = { min, max -> "$min – $max sat" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
||||||
|
memoOptional = "Oharra (aukerakoa)",
|
||||||
|
memoPlaceholder = "Zertarako da hau?",
|
||||||
|
creatingInvoice = "Faktura sortzen…",
|
||||||
|
createInvoice = "Sortu faktura",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
||||||
|
waiting = "Zain…",
|
||||||
|
invoiceExpired = "Faktura iraungita",
|
||||||
|
newInvoice = "Faktura berria",
|
||||||
|
|
||||||
|
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
||||||
|
withdrawFrom = "Atera hemendik:",
|
||||||
|
minimumSats = { min -> "Gutxienekoa $min sat da" },
|
||||||
|
maximumSats = { max -> "Gehienekoa $max sat da" },
|
||||||
|
withdrawButton = "Atera",
|
||||||
|
withdrawButtonAmount = { amount -> "Atera $amount sat" },
|
||||||
|
|
||||||
|
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
||||||
|
paymentReceived = "Ordainketa jasota!",
|
||||||
|
|
||||||
|
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
||||||
|
amountDisplaySats = { amount -> "$amount sat" },
|
||||||
|
|
||||||
|
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
||||||
|
brightnessReduce = "Murriztu distira",
|
||||||
|
brightnessBoost = "Handitu distira",
|
||||||
|
qrShare = "Partekatu",
|
||||||
|
qrCopy = "Kopiatu",
|
||||||
|
qrNfcStop = "Gelditu NFC partekatzea",
|
||||||
|
qrNfcStart = "Partekatu NFC bidez",
|
||||||
|
openInApp = "Ireki aplikazioan",
|
||||||
|
noAppFound = "Ez da aurkitu aplikazio bateragarririk",
|
||||||
|
|
||||||
|
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
||||||
|
receiveVmFailedToCreateInvoice = "Ezin izan da faktura sortu",
|
||||||
|
receiveVmWithdrawRejected = "Ateratzeko zerbitzuak faktura baztertu du",
|
||||||
|
receiveVmWithdrawFailed = "Ateratzeak huts egin du",
|
||||||
|
),
|
||||||
|
|
||||||
|
history = HistoryStrings(
|
||||||
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
|
historyTitle = "Historia",
|
||||||
|
historyClose = "Itxi",
|
||||||
|
historySearchPayments = "Bilatu ordainketak",
|
||||||
|
historyFilterPayments = "Iragazi ordainketak",
|
||||||
|
historyRetry = "Saiatu berriro",
|
||||||
|
historyNoPayments = "Oraindik ez dago ordainketarik.",
|
||||||
|
historySearchPlaceholder = "Bilatu transakzioak",
|
||||||
|
historySearchHelp = "Bilaketa-laguntza",
|
||||||
|
historySearchPaste = "Itsatsi",
|
||||||
|
historySearchClear = "Garbitu bilaketa",
|
||||||
|
historySearchChip = { tail -> "bilaketa: …$tail" },
|
||||||
|
historyClearSearch = "Garbitu bilaketa",
|
||||||
|
filterDirectionOutgoing = "Irteerako",
|
||||||
|
filterDirectionIncoming = "Sarrerako",
|
||||||
|
filterClearDirection = "Garbitu norabide-iragazkia",
|
||||||
|
filterStatusCompleted = "Osatuta",
|
||||||
|
filterStatusPending = "Zain",
|
||||||
|
filterStatusFailed = "Huts eginda",
|
||||||
|
filterRemoveStatus = "Kendu egoera-iragazkia",
|
||||||
|
filterRemoveType = "Kendu mota-iragazkia",
|
||||||
|
filterAmountRange = { min, max -> "$min–$max sat" },
|
||||||
|
filterAmountMin = { min -> "≥ $min sat" },
|
||||||
|
filterAmountMax = { max -> "≤ $max sat" },
|
||||||
|
filterClearAmount = "Garbitu zenbateko-iragazkia",
|
||||||
|
filterDateThisWeek = "Aste honetan",
|
||||||
|
filterDateThisMonth = "Hilabete honetan",
|
||||||
|
filterDateThisYear = "Urte honetan",
|
||||||
|
filterDateRange = { from, to -> "$from–$to" },
|
||||||
|
filterDateFrom = { from -> "$from-tik" },
|
||||||
|
filterDateUntil = { until -> "$until arte" },
|
||||||
|
filterClearDate = "Garbitu data-iragazkia",
|
||||||
|
|
||||||
|
// ── FilterBottomSheet ─────────────────────────────────────────────────────
|
||||||
|
filterSheetTitle = "Iragazi ordainketak",
|
||||||
|
filterSectionDirection = "Norabidea",
|
||||||
|
filterDirectionAll = "Guztiak",
|
||||||
|
filterSectionStatus = "Egoera",
|
||||||
|
filterSectionType = "Mota",
|
||||||
|
filterSectionAmount = "Zenbatekoa (sat)",
|
||||||
|
filterAmountMin_label = "Gutxienekoa",
|
||||||
|
filterAmountMax_label = "Gehienekoa",
|
||||||
|
filterAmountSuffix = "sat",
|
||||||
|
filterSectionDate = "Data",
|
||||||
|
filterDateFrom_label = "Nondik",
|
||||||
|
filterDateTo_label = "Nora arte",
|
||||||
|
filterDialogOk = "Ados",
|
||||||
|
filterDialogCancel = "Utzi",
|
||||||
|
|
||||||
|
// ── SearchInfoSheet ───────────────────────────────────────────────────────
|
||||||
|
searchInfoTitle = "Zer bila dezaket?",
|
||||||
|
searchInfoSubtitle = "Idatzi hurrengoetako edozein zati ordainketa bat aurkitzeko:",
|
||||||
|
searchInfoMemoTitle = "Oharra",
|
||||||
|
searchInfoMemoDesc = "Ordainketa bati erantsitako mezua edo deskribapena. " +
|
||||||
|
"Adibidez, \"kafea\" edo \"martxoko alokairua\".",
|
||||||
|
searchInfoInvoiceTitle = "Faktura",
|
||||||
|
searchInfoInvoiceDesc = "Ordainketa bat bidaltzeko eskaneatu edo itsatsi zenuen ordainketa-eskaera. " +
|
||||||
|
"Normalean \"lnbc…\" hasierarekin dator. " +
|
||||||
|
"Lehen edo azken karaktere batzuk bakarrik itsats ditzakezu.",
|
||||||
|
searchInfoHashTitle = "Hash",
|
||||||
|
searchInfoHashDesc = "Ordainketa hau Lightning Sarean identifikatzen duen kode bakarra. " +
|
||||||
|
"Erabilgarria norbaitek transakzio zehatz bat berresteko eskatzen dizunean.",
|
||||||
|
searchInfoPreimageTitle = "Aurre-irudia",
|
||||||
|
searchInfoPreimageDesc = "Ordainketa bat jaso dela frogatzen duen kode sekretua. " +
|
||||||
|
"Ordainketa arrakastaz osatu ondoren bakarrik dago erabilgarri.",
|
||||||
|
searchInfoNodeAliasTitle = "Nodo-izengoitia",
|
||||||
|
searchInfoNodeAliasDesc = "Ordainketa bidali zenion Lightning nodoaren izena. " +
|
||||||
|
"Adibidez, \"ACINQ\" edo \"Wallet of Satoshi\". " +
|
||||||
|
"Izengoitiak automatikoki ebazten dira irteerako ordainketetan.",
|
||||||
|
searchInfoNodeIdTitle = "Nodo IDa (gako publikoa)",
|
||||||
|
searchInfoNodeIdDesc = "Helmuga-nodoaren gako publikoa. " +
|
||||||
|
"66 karaktereko hex katea — lehen karaktere batzuk bakarrik itsats ditzakezu.",
|
||||||
|
searchInfoFooter = "Balio osoa itsatsi dezakezu edo karaktere gutxi batzuekin bilatu — " +
|
||||||
|
"zati-bat datozenak ere funtzionatzen dute.",
|
||||||
|
|
||||||
|
// ── PaymentDetailScreen ───────────────────────────────────────────────────
|
||||||
|
statusSuccess = "Osatuta",
|
||||||
|
statusPending = "Zain",
|
||||||
|
statusFailed = "Huts eginda",
|
||||||
|
statusUnknown = "Ezezaguna",
|
||||||
|
detailOutgoingTitle = "Irteerako ordainketa",
|
||||||
|
detailIncomingTitle = "Sarrerako ordainketa",
|
||||||
|
detailBack = "Atzera",
|
||||||
|
detailCouldNotLoad = "Ezin izan dira xehetasun osoak kargatu",
|
||||||
|
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
|
||||||
|
"Komisioa: $feeSat sat$fiatPart$ppmPart"
|
||||||
|
},
|
||||||
|
detailSectionDetails = "Xehetasunak",
|
||||||
|
detailRowDate = "Data",
|
||||||
|
detailRowMemo = "Oharra",
|
||||||
|
detailRowMemoEmpty = "—",
|
||||||
|
detailRowType = "Mota",
|
||||||
|
detailRowComment = "Iruzkina",
|
||||||
|
detailSectionSuccessAction = "Arrakasta-ekintza",
|
||||||
|
detailSuccessMessage = "Mezua",
|
||||||
|
detailSuccessUrl = "URLa",
|
||||||
|
detailSuccessDescription = "Deskribapena",
|
||||||
|
detailSectionTechnical = "Teknikoa",
|
||||||
|
detailRowPaymentHash = "Hash",
|
||||||
|
detailRowPreimage = "Aurre-irudia",
|
||||||
|
detailRowDestination = "Helmuga",
|
||||||
|
detailCopyNodeId = "Kopiatu nodo IDa",
|
||||||
|
detailRowBolt11 = "BOLT11",
|
||||||
|
|
||||||
|
// ── PaymentRow ────────────────────────────────────────────────────────────
|
||||||
|
paymentRowSentCD = "Bidalita",
|
||||||
|
paymentRowReceivedCD = "Jasota",
|
||||||
|
paymentRowNoMemo = "Oharrik gabe",
|
||||||
|
paymentRowPending = "Zain",
|
||||||
|
paymentRowFailed = "Huts eginda",
|
||||||
|
),
|
||||||
|
|
||||||
|
nwc = NwcStrings(
|
||||||
|
// ── Shared ────────────────────────────────────────────────────────────────
|
||||||
|
back = "Atzera",
|
||||||
|
next = "Hurrengoa",
|
||||||
|
done = "Eginda",
|
||||||
|
cancel = "Utzi",
|
||||||
|
schedule = "Programatu",
|
||||||
|
|
||||||
|
// ── Connection detail ─────────────────────────────────────────────────────
|
||||||
|
connectionDetailTitle = "Konexioa",
|
||||||
|
connectionUnknownWallet = "App ezezaguna",
|
||||||
|
connectionSectionDetails = "Xehetasunak",
|
||||||
|
connectionRowCreated = "Konektatuta",
|
||||||
|
connectionRowLastUsed = "Azken erabilera",
|
||||||
|
lastUsedToday = "Duela gutxi erabilia", // "used recently" (idiomatic for today)
|
||||||
|
lastUsedYesterday = "Atzo erabilia", // "used yesterday"
|
||||||
|
lastUsedDate = "Duela %s erabilia", // "used [date] ago"
|
||||||
|
connectionRowExpires = "Iraungitze-data",
|
||||||
|
connectionRowPubkey = "Gako publikoa",
|
||||||
|
connectionNeverUsed = "Inoiz ez",
|
||||||
|
connectionNoExpiry = "Iraungitze-datarik ez",
|
||||||
|
connectionExpired = { date -> "Iraungita · $date" },
|
||||||
|
connectionSectionPermissions = "Baimenak",
|
||||||
|
connectionSectionBudgets = "Gastu-aurrekontuak",
|
||||||
|
connectionBudgetTitle = { window -> "$window aurrekontua" },
|
||||||
|
connectionBudgetUsed = { sats -> "Erabilita: $sats sat" },
|
||||||
|
connectionBudgetTotal = { sats -> "$sats satetatik" },
|
||||||
|
connectionRevoke = "Konexioa baliogabetu",
|
||||||
|
connectionStatusActive = "Aktibo",
|
||||||
|
connectionStatusExpired = "Iraungita",
|
||||||
|
|
||||||
|
// ── NWC permissions ───────────────────────────────────────────────────────
|
||||||
|
permPayInvoice = "Fakturak ordaindu",
|
||||||
|
permGetBalance = "Saldoa ikusi",
|
||||||
|
permGetInfo = "Zorroko informazioa ikusi",
|
||||||
|
permMakeInvoice = "Fakturak sortu",
|
||||||
|
permLookupInvoice = "Fakturak kontsultatu",
|
||||||
|
permListTransactions = "Transakzio-historia ikusi",
|
||||||
|
permSignMessage = "Mezuak sinatu",
|
||||||
|
permPay = "Ordainketak bidali",
|
||||||
|
permInvoice = "Fakturak sortu",
|
||||||
|
permLookup = "Kontsultatu faktura-egoera",
|
||||||
|
permHistory = "Irakurri transakzio-historiala",
|
||||||
|
permBalance = "Irakurri zorroaren saldoa",
|
||||||
|
permInfo = "Irakurri kontu-informazioa",
|
||||||
|
permLookupHistory = "Egoera eta transakzio-historia kontsultatu",
|
||||||
|
permBalanceInfo = "Saldoa eta kontuaren informazioa ikusi",
|
||||||
|
permSendPayments = "Ordainketak bidali",
|
||||||
|
permCreateInvoices = "Fakturak sortu",
|
||||||
|
permLookupInvoiceStatus = "Fakturaren egoera kontsultatu",
|
||||||
|
permReadTransactionHistory = "Transakzio-historia irakurri",
|
||||||
|
permReadWalletBalance = "Zorroko saldoa irakurri",
|
||||||
|
permReadAccountInfo = "Kontuaren informazioa irakurri",
|
||||||
|
revokeDialogTitle = "Konexioa baliogabetu?",
|
||||||
|
revokeDialogBody = "Honek Nostr Wallet Connect gako hau behin betiko baliogabetuko du. Erabiltzen duen edozein aplikaziok sarbidea galduko du.",
|
||||||
|
revokeDialogConfirm = "Baliogabetu",
|
||||||
|
revokeDialogDismiss = "Utzi",
|
||||||
|
refreshDaily = "Egunero",
|
||||||
|
refreshWeekly = "Astero",
|
||||||
|
refreshMonthly = "Hilabetero",
|
||||||
|
refreshYearly = "Urtero",
|
||||||
|
refreshNever = "Inoiz ez",
|
||||||
|
|
||||||
|
newConnection = "Konexio berria",
|
||||||
|
labelHint = "Etiketa (adb. Amethyst, Bitrefill)",
|
||||||
|
expires = "Iraungitzen da",
|
||||||
|
pickDate = "Aukeratu data",
|
||||||
|
pickTime = "Aukeratu ordua",
|
||||||
|
never = "Inoiz ez",
|
||||||
|
budgets = "Aurrekontuak",
|
||||||
|
addBudget = "Gehitu aurrekontua",
|
||||||
|
removeBudget = "Kendu aurrekontua",
|
||||||
|
permissions = "Baimenak",
|
||||||
|
unnamed = "Izengabea",
|
||||||
|
creating = "Sortzen…",
|
||||||
|
createConnection = "Sortu konexioa",
|
||||||
|
ok = "Ados",
|
||||||
|
sats = "Sats",
|
||||||
|
resets = "Berrezarri",
|
||||||
|
|
||||||
|
deleteConnection = "Ezabatu konexioa",
|
||||||
|
unnamedConnection = "Izengabeko konexioa",
|
||||||
|
neverUsed = "Inoiz erabili gabea",
|
||||||
|
expired = "Iraungitua",
|
||||||
|
|
||||||
|
connectionCreated = "Konexioa sortuta",
|
||||||
|
scanOrCopyHint = "Eskaneatu edo kopiatu konexio-kate hau zure app-ean. Ez da berriro erakutsiko.",
|
||||||
|
nwcPairingQrCode = "NWC parekatzeko QR kodea",
|
||||||
|
nwcConnectionString = "NWC konexio-katea",
|
||||||
|
shareNwcConnectionString = "Partekatu NWC konexio-katea",
|
||||||
|
openInApp = "Ireki app-ean",
|
||||||
|
|
||||||
|
walletConnect = "Wallet Connect",
|
||||||
|
addConnection = "Gehitu konexioa",
|
||||||
|
noCompatibleAppFound = "Ez da app bateragarririk aurkitu",
|
||||||
|
removeConnection = "Kendu konexioa?",
|
||||||
|
removeConnectionDetail = "Honek Nostr Wallet Connect gako hau behin betiko indargabetuko du. Erabiltzen duen edozein app-ek sarbidea galduko du.",
|
||||||
|
remove = "Kendu",
|
||||||
|
|
||||||
|
noConnectionsYet = "Oraindik konexiorik ez",
|
||||||
|
tapToAddHint = "Sakatu + app bat Nostr Wallet Connect bidez konektatzeko.",
|
||||||
|
somethingWentWrong = "Zerbait gaizki atera da",
|
||||||
|
retry = "Saiatu berriro",
|
||||||
|
|
||||||
|
failedToLoadConnections = "Huts egin du konexioak kargatzean",
|
||||||
|
failedToCreateConnection = "Huts egin du konexioa sortzean",
|
||||||
|
failedToDeleteConnection = "Huts egin du konexioa ezabatzean",
|
||||||
|
failedToLoadConnection = "Huts egin du konexioa kargatzean",
|
||||||
|
),
|
||||||
|
lock = LockStrings(
|
||||||
|
appLockTitle = "App Blokeoa",
|
||||||
|
appLockDescription = "Autentifikazio biometrikoa (hatz-marka, aurpegia edo PINa) eskatu Gudari Wallet irekitzen den bakoitzean.",
|
||||||
|
appLockToggleLabel = "Aplikazioa irekitzean blokeatu",
|
||||||
|
appLockToggleEnabled = "Gaituta (gomendatua)",
|
||||||
|
appLockToggleDisabled = "Desgaituta",
|
||||||
|
appName = "Gudari Wallet",
|
||||||
|
authenticateToContinue = "Autentifikatu jarraitzeko",
|
||||||
|
unlockButton = "Desblokeatu",
|
||||||
|
biometricPromptTitle = "Desblokeatu Gudari Wallet",
|
||||||
|
biometricPromptSubtitle = "Autentifikatu zure zorrora sartzeko",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,61 +63,8 @@ data class AppStrings(
|
|||||||
val details : String,
|
val details : String,
|
||||||
val enterValidAmount : String,
|
val enterValidAmount : String,
|
||||||
val satsUnit : (sats: Long) -> String,
|
val satsUnit : (sats: Long) -> String,
|
||||||
|
val today : String,
|
||||||
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
val yesterday : String,
|
||||||
val receive : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
|
||||||
val receiveError : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
|
||||||
val lightningAddressQrCode : String,
|
|
||||||
val lightningAddressLabel : String,
|
|
||||||
val shareLightningAddress : String,
|
|
||||||
val lightningInvoiceQrCode : String,
|
|
||||||
val invoiceLabel : String,
|
|
||||||
val shareInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
|
||||||
val amountInSats : String, // text field label
|
|
||||||
val satsRange : (min: Long, max: Long) -> String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
|
||||||
val memoOptional : String,
|
|
||||||
val memoPlaceholder : String,
|
|
||||||
val creatingInvoice : String,
|
|
||||||
val createInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
|
||||||
val waiting : String,
|
|
||||||
val invoiceExpired : String,
|
|
||||||
val newInvoice : String,
|
|
||||||
|
|
||||||
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
|
||||||
val withdrawFrom : String,
|
|
||||||
val minimumSats : (min: Long) -> String,
|
|
||||||
val maximumSats : (max: Long) -> String,
|
|
||||||
val withdrawButton : String, // no fixed amount
|
|
||||||
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
|
|
||||||
|
|
||||||
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
|
||||||
val paymentReceived : String,
|
|
||||||
|
|
||||||
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
|
||||||
val amountDisplaySats : (amount: Long) -> String,
|
|
||||||
|
|
||||||
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
|
||||||
val brightnessReduce : String,
|
|
||||||
val brightnessBoost : String,
|
|
||||||
val qrShare : String,
|
|
||||||
val qrCopy : String,
|
|
||||||
val qrNfcStop : String,
|
|
||||||
val qrNfcStart : String,
|
|
||||||
|
|
||||||
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
|
||||||
val receiveVmFailedToCreateInvoice : String,
|
|
||||||
val receiveVmWithdrawRejected : String,
|
|
||||||
val receiveVmWithdrawFailed : String,
|
|
||||||
|
|
||||||
// ── SendScreen — general ──────────────────────────────────────────────────
|
// ── SendScreen — general ──────────────────────────────────────────────────
|
||||||
val send : String,
|
val send : String,
|
||||||
@@ -204,6 +151,120 @@ data class AppStrings(
|
|||||||
val sendVmUnsupportedType : (tag: String) -> String,
|
val sendVmUnsupportedType : (tag: String) -> String,
|
||||||
val sendVmPaymentFailedRescan : String,
|
val sendVmPaymentFailedRescan : String,
|
||||||
|
|
||||||
|
val onboarding: OnboardingStrings,
|
||||||
|
val receive: ReceiveStrings,
|
||||||
|
val history: HistoryStrings,
|
||||||
|
val nwc: NwcStrings,
|
||||||
|
val lock: LockStrings
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OnboardingStrings(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val serverUrl: String,
|
||||||
|
val serverUrlHint: String,
|
||||||
|
val invoiceKey: String,
|
||||||
|
val invoiceKeySubtitle: String,
|
||||||
|
val pasteInvoiceKey: String,
|
||||||
|
val adminKey: String,
|
||||||
|
val adminKeySubtitle: String,
|
||||||
|
val pasteAdminKey: String,
|
||||||
|
val showAdminKey: String,
|
||||||
|
val hideAdminKey: String,
|
||||||
|
val connect: String,
|
||||||
|
val fieldsRequired: String,
|
||||||
|
val invalidKeyFormat: String,
|
||||||
|
val back: String,
|
||||||
|
val next: String,
|
||||||
|
val done: String,
|
||||||
|
val notificationsTitle: String,
|
||||||
|
val notificationsSubtitle: String,
|
||||||
|
val notificationsAlreadyGranted: String,
|
||||||
|
val allowNotifications: String,
|
||||||
|
val notificationsNote: String,
|
||||||
|
val notificationsAutoGranted: String,
|
||||||
|
val batteryTitle: String,
|
||||||
|
val batterySubtitle: String,
|
||||||
|
val openSettings: String,
|
||||||
|
val batteryNote: String,
|
||||||
|
val batteryAlreadyUnrestricted: String,
|
||||||
|
val paymentHistory: String,
|
||||||
|
val paymentHistorySubtitle: String,
|
||||||
|
val syncNow: String,
|
||||||
|
val skipForNow: String,
|
||||||
|
val syncScheduled: String,
|
||||||
|
val continueToApp: String,
|
||||||
|
val connecting: String,
|
||||||
|
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
|
||||||
|
val paymentsSynced: (total: Int) -> String,
|
||||||
|
val syncComplete: String,
|
||||||
|
val syncFailed: String,
|
||||||
|
val scheduleForLater: String,
|
||||||
|
val scheduleSync: String,
|
||||||
|
val schedule: String,
|
||||||
|
val cancel: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReceiveStrings(
|
||||||
|
// ── ReceiveScreen — general ───────────────────────────────────────────────
|
||||||
|
val receive : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — error ─────────────────────────────────────────────────
|
||||||
|
val receiveError : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — QR / share ────────────────────────────────────────────
|
||||||
|
val lightningAddressQrCode : String,
|
||||||
|
val lightningAddressLabel : String,
|
||||||
|
val shareLightningAddress : String,
|
||||||
|
val lightningInvoiceQrCode : String,
|
||||||
|
val invoiceLabel : String,
|
||||||
|
val shareInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — amount step ───────────────────────────────────────────
|
||||||
|
val amountInSats : String, // text field label
|
||||||
|
val satsRange : (min: Long, max: Long) -> String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — memo step ─────────────────────────────────────────────
|
||||||
|
val memoOptional : String,
|
||||||
|
val memoPlaceholder : String,
|
||||||
|
val creatingInvoice : String,
|
||||||
|
val createInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — invoice ready ─────────────────────────────────────────
|
||||||
|
val waiting : String,
|
||||||
|
val invoiceExpired : String,
|
||||||
|
val newInvoice : String,
|
||||||
|
|
||||||
|
// ── ReceiveScreen — lnurl withdraw ────────────────────────────────────────
|
||||||
|
val withdrawFrom : String,
|
||||||
|
val minimumSats : (min: Long) -> String,
|
||||||
|
val maximumSats : (max: Long) -> String,
|
||||||
|
val withdrawButton : String, // no fixed amount
|
||||||
|
val withdrawButtonAmount : (amountSats: Long) -> String, // fixed amount
|
||||||
|
|
||||||
|
// ── ReceiveScreen — success ───────────────────────────────────────────────
|
||||||
|
val paymentReceived : String,
|
||||||
|
|
||||||
|
// ── AmountDisplay ─────────────────────────────────────────────────────────
|
||||||
|
val amountDisplaySats : (amount: Long) -> String,
|
||||||
|
|
||||||
|
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
|
||||||
|
val brightnessReduce : String,
|
||||||
|
val brightnessBoost : String,
|
||||||
|
val qrShare : String,
|
||||||
|
val qrCopy : String,
|
||||||
|
val qrNfcStop : String,
|
||||||
|
val qrNfcStart : String,
|
||||||
|
val openInApp : String,
|
||||||
|
val noAppFound : String,
|
||||||
|
|
||||||
|
// ── ReceiveViewModel ──────────────────────────────────────────────────────
|
||||||
|
val receiveVmFailedToCreateInvoice : String,
|
||||||
|
val receiveVmWithdrawRejected : String,
|
||||||
|
val receiveVmWithdrawFailed : String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HistoryStrings(
|
||||||
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
// ── HistoryScreen ─────────────────────────────────────────────────────────
|
||||||
val historyTitle : String,
|
val historyTitle : String,
|
||||||
val historyClose : String,
|
val historyClose : String,
|
||||||
@@ -303,52 +364,133 @@ data class AppStrings(
|
|||||||
val paymentRowNoMemo : String,
|
val paymentRowNoMemo : String,
|
||||||
val paymentRowPending : String,
|
val paymentRowPending : String,
|
||||||
val paymentRowFailed : String,
|
val paymentRowFailed : String,
|
||||||
|
|
||||||
val onboarding: OnboardingStrings
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OnboardingStrings(
|
data class NwcStrings(
|
||||||
val title: String,
|
|
||||||
val subtitle: String,
|
// ── Shared / navigation ───────────────────────────────────────────────────
|
||||||
val serverUrl: String,
|
val back : String,
|
||||||
val serverUrlHint: String,
|
val next : String,
|
||||||
val invoiceKey: String,
|
val done : String,
|
||||||
val invoiceKeySubtitle: String,
|
val cancel : String,
|
||||||
val pasteInvoiceKey: String,
|
val schedule : String,
|
||||||
val adminKey: String,
|
|
||||||
val adminKeySubtitle: String,
|
// ── Connection detail ─────────────────────────────────────────────────────
|
||||||
val pasteAdminKey: String,
|
val connectionDetailTitle : String,
|
||||||
val showAdminKey: String,
|
val connectionUnknownWallet : String,
|
||||||
val hideAdminKey: String,
|
val connectionSectionDetails : String,
|
||||||
val connect: String,
|
val connectionRowCreated : String,
|
||||||
val fieldsRequired: String,
|
val connectionRowLastUsed : String,
|
||||||
val back: String,
|
val connectionRowExpires : String,
|
||||||
val next: String,
|
val connectionRowPubkey : String,
|
||||||
val done: String,
|
val connectionNeverUsed : String,
|
||||||
val notificationsTitle: String,
|
val lastUsedToday : String, // e.g. "Last used today"
|
||||||
val notificationsSubtitle: String,
|
val lastUsedYesterday : String, // e.g. "Last used yesterday"
|
||||||
val notificationsAlreadyGranted: String,
|
val lastUsedDate : String, // e.g. "Last used %s" — %s is a formatted date
|
||||||
val allowNotifications: String,
|
val connectionNoExpiry : String,
|
||||||
val notificationsNote: String,
|
/** "Expired · {date}" */
|
||||||
val notificationsAutoGranted: String,
|
val connectionExpired : (date: String) -> String,
|
||||||
val batteryTitle: String,
|
val connectionSectionPermissions : String,
|
||||||
val batterySubtitle: String,
|
val connectionSectionBudgets : String,
|
||||||
val openSettings: String,
|
/** "{window} budget" e.g. "Daily budget" */
|
||||||
val batteryNote: String,
|
val connectionBudgetTitle : (window: String) -> String,
|
||||||
val batteryAlreadyUnrestricted: String,
|
/** "Used: {sats} sats" */
|
||||||
val paymentHistory: String,
|
val connectionBudgetUsed : (sats: Long) -> String,
|
||||||
val paymentHistorySubtitle: String,
|
/** "of {sats} sats" */
|
||||||
val syncNow: String,
|
val connectionBudgetTotal : (sats: Long) -> String,
|
||||||
val skipForNow: String,
|
val connectionRevoke : String,
|
||||||
val syncScheduled: String,
|
val connectionStatusActive : String,
|
||||||
val continueToApp: String,
|
val connectionStatusExpired : String,
|
||||||
val connecting: String,
|
|
||||||
val paymentsFetchedTotal: (fetched: Int, total: Int) -> String,
|
// ── NWC permission labels ─────────────────────────────────────────────────
|
||||||
val paymentsSynced: (total: Int) -> String,
|
val permPayInvoice : String,
|
||||||
val syncComplete: String,
|
val permGetBalance : String,
|
||||||
val syncFailed: String,
|
val permGetInfo : String,
|
||||||
val scheduleForLater: String,
|
val permMakeInvoice : String,
|
||||||
val scheduleSync: String,
|
val permLookupInvoice : String,
|
||||||
val schedule: String,
|
val permListTransactions : String,
|
||||||
val cancel: String
|
val permSignMessage : String,
|
||||||
|
val permPay : String, // "Send payments"
|
||||||
|
val permInvoice : String, // "Create invoices"
|
||||||
|
val permLookup : String,
|
||||||
|
val permHistory : String,
|
||||||
|
val permBalance : String,
|
||||||
|
val permInfo : String,
|
||||||
|
val permLookupHistory : String, // "Lookup status & transaction history"
|
||||||
|
val permBalanceInfo : String, // "Read balance & account info"
|
||||||
|
val permSendPayments : String,
|
||||||
|
val permCreateInvoices : String,
|
||||||
|
val permLookupInvoiceStatus: String,
|
||||||
|
val permReadTransactionHistory: String,
|
||||||
|
val permReadWalletBalance: String,
|
||||||
|
val permReadAccountInfo: String,
|
||||||
|
val revokeDialogTitle : String,
|
||||||
|
val revokeDialogBody : String,
|
||||||
|
val revokeDialogConfirm : String,
|
||||||
|
val revokeDialogDismiss : String,
|
||||||
|
val refreshDaily : String,
|
||||||
|
val refreshWeekly : String,
|
||||||
|
val refreshMonthly : String,
|
||||||
|
val refreshYearly : String,
|
||||||
|
val refreshNever : String,
|
||||||
|
|
||||||
|
// New Connection
|
||||||
|
val newConnection : String,
|
||||||
|
val labelHint : String,
|
||||||
|
val expires : String,
|
||||||
|
val pickDate : String,
|
||||||
|
val pickTime : String,
|
||||||
|
val never : String,
|
||||||
|
val budgets : String,
|
||||||
|
val addBudget : String,
|
||||||
|
val removeBudget : String,
|
||||||
|
val permissions : String,
|
||||||
|
val unnamed : String,
|
||||||
|
val creating : String,
|
||||||
|
val createConnection : String,
|
||||||
|
val ok : String,
|
||||||
|
val sats : String,
|
||||||
|
val resets : String,
|
||||||
|
|
||||||
|
val deleteConnection : String,
|
||||||
|
val unnamedConnection : String,
|
||||||
|
val neverUsed : String,
|
||||||
|
val expired : String,
|
||||||
|
|
||||||
|
val connectionCreated : String,
|
||||||
|
val scanOrCopyHint : String,
|
||||||
|
val nwcPairingQrCode : String,
|
||||||
|
val nwcConnectionString : String,
|
||||||
|
val shareNwcConnectionString : String,
|
||||||
|
val openInApp : String,
|
||||||
|
|
||||||
|
val walletConnect : String,
|
||||||
|
val addConnection : String,
|
||||||
|
val noCompatibleAppFound : String,
|
||||||
|
val removeConnection : String,
|
||||||
|
val removeConnectionDetail: String,
|
||||||
|
val remove : String,
|
||||||
|
|
||||||
|
val noConnectionsYet : String,
|
||||||
|
val tapToAddHint : String,
|
||||||
|
val somethingWentWrong : String,
|
||||||
|
val retry : String,
|
||||||
|
|
||||||
|
val failedToLoadConnections : String,
|
||||||
|
val failedToCreateConnection : String,
|
||||||
|
val failedToDeleteConnection : String,
|
||||||
|
val failedToLoadConnection : String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LockStrings(
|
||||||
|
val appLockTitle: String,
|
||||||
|
val appLockDescription: String,
|
||||||
|
val appLockToggleLabel: String,
|
||||||
|
val appLockToggleEnabled: String,
|
||||||
|
val appLockToggleDisabled: String,
|
||||||
|
val appName: String,
|
||||||
|
val authenticateToContinue: String,
|
||||||
|
val unlockButton: String,
|
||||||
|
val biometricPromptTitle: String,
|
||||||
|
val biometricPromptSubtitle: String,
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.bitcointxoko.gudariwallet.i18n
|
package com.bitcointxoko.gudariwallet.i18n
|
||||||
|
|
||||||
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
|
fun AppStrings.paymentStatusLabel(status: String): String = when (status.lowercase()) {
|
||||||
"success" -> statusSuccess
|
"success" -> history.statusSuccess
|
||||||
"pending" -> statusPending
|
"pending" -> history.statusPending
|
||||||
"failed" -> statusFailed
|
"failed" -> history.statusFailed
|
||||||
else -> statusUnknown
|
else -> history.statusUnknown
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
package com.bitcointxoko.gudariwallet.security
|
package com.bitcointxoko.gudariwallet.security
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import timber.log.Timber
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.migrations.SharedPreferencesMigration
|
import androidx.datastore.migrations.SharedPreferencesMigration
|
||||||
import androidx.datastore.migrations.SharedPreferencesView
|
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton DataStore for encrypted credentials.
|
* Singleton DataStore for encrypted credentials.
|
||||||
@@ -27,8 +22,6 @@ import androidx.core.content.edit
|
|||||||
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
|
* [cleanUpLegacyPrefs] once you've confirmed migration succeeded.
|
||||||
*/
|
*/
|
||||||
object CredentialsDataStore {
|
object CredentialsDataStore {
|
||||||
private const val TAG = "CredentialsDataStore"
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: DataStore<Credentials>? = null
|
private var instance: DataStore<Credentials>? = null
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class CredentialsSerializer(
|
|||||||
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
|
Timber.d("Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}")
|
||||||
result
|
result
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Decryption failed — DataStore may be corrupt or key rotated", e)
|
Timber.e("Decryption failed — DataStore may be corrupt or key rotated")
|
||||||
throw CorruptionException("Cannot read/decrypt credentials DataStore", e)
|
throw CorruptionException("Cannot read/decrypt credentials DataStore")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +37,10 @@ class CredentialsSerializer(
|
|||||||
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
|
val encryptingStream = streamingAead.newEncryptingStream(output, AAD)
|
||||||
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
|
t.writeTo(encryptingStream) // returns Int — discarded by Unit return type
|
||||||
encryptingStream.close()
|
encryptingStream.close()
|
||||||
Timber.d("Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
|
Timber.d("%snull", "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " +
|
||||||
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " +
|
"hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, ")
|
||||||
"hasAdminKey: ${t.adminKey.isNotBlank()}, " +
|
|
||||||
"hasBaseUrl: ${t.baseUrl.isNotBlank()}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Encryption/write failed", e)
|
Timber.e("Encryption/write failed")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ object CredentialsTinkManager {
|
|||||||
Timber.i("StreamingAead primitive acquired successfully")
|
Timber.i("StreamingAead primitive acquired successfully")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable", e)
|
Timber.e("Failed to initialise StreamingAead — Keystore may be unavailable")
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class NodeAliasService(
|
|||||||
repo.put(pubkey, alias)
|
repo.put(pubkey, alias)
|
||||||
|
|
||||||
// 4. Publish to StateFlow
|
// 4. Publish to StateFlow
|
||||||
_aliases.value = _aliases.value + (pubkey to alias)
|
_aliases.value += (pubkey to alias)
|
||||||
|
|
||||||
Timber.d("ALIAS [FETCHED ] $pubkey → \"$alias\"")
|
Timber.d("ALIAS [FETCHED ] $pubkey → \"$alias\"")
|
||||||
return alias
|
return alias
|
||||||
|
|||||||
+7
-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,235 +54,258 @@ fun HomeTab(
|
|||||||
|
|
||||||
val isRefreshing = balanceState is BalanceState.Loading
|
val isRefreshing = balanceState is BalanceState.Loading
|
||||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
onRefresh = { vm.refreshBalance() },
|
onRefresh = { vm.refreshBalance() },
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
when (val s = balanceState) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
is BalanceState.Loading -> {
|
.fillMaxSize()
|
||||||
CircularProgressIndicator()
|
.verticalScroll(rememberScrollState())
|
||||||
Spacer(Modifier.height(12.dp))
|
.padding(24.dp),
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
text = strings.loadingBalance,
|
verticalArrangement = Arrangement.Center
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
when (val s = balanceState) {
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is BalanceState.Success -> {
|
|
||||||
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
|
||||||
var showCurrencyPicker by remember { mutableStateOf(false) }
|
|
||||||
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
|
||||||
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
|
||||||
LaunchedEffect(showCurrencyPicker) {
|
|
||||||
if (showCurrencyPicker && currencyList.isEmpty()) {
|
|
||||||
isLoadingCurrencies = true
|
|
||||||
currencyList = vm.getSupportedCurrencies()
|
|
||||||
isLoadingCurrencies = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (balanceHidden)
|
|
||||||
Icons.Filled.VisibilityOff
|
|
||||||
else
|
|
||||||
Icons.Filled.Visibility,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
|
|
||||||
|
is BalanceState.Loading -> {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (balanceHidden) "••••••" else formatSats(s.sats),
|
text = strings.loadingBalance,
|
||||||
style = MaterialTheme.typography.displayLarge.copy(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = if (balanceHidden)
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.primary,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
|
|
||||||
onClickLabel = if (balanceHidden) null else strings.hideBalance,
|
|
||||||
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
|
|
||||||
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = strings.sats,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
val currency = selectedCurrency
|
is BalanceState.Success -> {
|
||||||
if (currency != null) {
|
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||||
Row(
|
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||||
horizontalArrangement = Arrangement.Center
|
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
) {
|
LaunchedEffect(showCurrencyPicker) {
|
||||||
if (s.fiatAmount != null && s.fiatCurrency != null) {
|
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||||
Text(
|
isLoadingCurrencies = true
|
||||||
text = if (balanceHidden) "••••"
|
currencyList = vm.getSupportedCurrencies()
|
||||||
else formatFiat(s.fiatAmount, s.fiatCurrency),
|
isLoadingCurrencies = false
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = if (balanceHidden)
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
}
|
}
|
||||||
Row(
|
}
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Row(
|
||||||
modifier = Modifier
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.clickable(
|
) {
|
||||||
onClick = { showCurrencyPicker = true },
|
Icon(
|
||||||
onClickLabel = strings.changeCurrency,
|
imageVector = if (balanceHidden)
|
||||||
indication = null,
|
Icons.Filled.VisibilityOff
|
||||||
|
else
|
||||||
|
Icons.Filled.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (balanceHidden) "••••••" else formatSats(s.sats),
|
||||||
|
style = MaterialTheme.typography.displayLarge.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = if (balanceHidden)
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 280.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
|
||||||
|
onClickLabel = if (balanceHidden) null else strings.hideBalance,
|
||||||
|
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
|
||||||
|
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
|
||||||
|
indication = null,
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
)
|
)
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = strings.sats,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
val currency = selectedCurrency
|
||||||
|
if (currency != null) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (s.fiatAmount != null && s.fiatCurrency != null) {
|
||||||
|
Text(
|
||||||
|
text = if (balanceHidden) "••••"
|
||||||
|
else formatFiat(s.fiatAmount, s.fiatCurrency),
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = if (balanceHidden)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = { showCurrencyPicker = true },
|
||||||
|
onClickLabel = strings.changeCurrency,
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = currency,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.75f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowDropDown,
|
||||||
|
contentDescription = strings.changeCurrency,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(
|
||||||
|
onClick = { showCurrencyPicker = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = currency,
|
text = strings.showFiat,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ArrowDropDown,
|
|
||||||
contentDescription = strings.changeCurrency,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
modifier = Modifier.size(14.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
TextButton(
|
Spacer(Modifier.height(8.dp))
|
||||||
onClick = { showCurrencyPicker = true },
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
if (!s.isRefreshing) {
|
||||||
) {
|
val updatedLabel: String = remember(s.lastUpdated) {
|
||||||
|
formatLastUpdated(s.lastUpdated, strings)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = strings.showFiat,
|
text = strings.updatedAt(updatedLabel),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
if (!s.isRefreshing) {
|
TextButton(onClick = onHistoryClick) {
|
||||||
val updatedLabel: String = remember(s.lastUpdated) {
|
Text(
|
||||||
formatLastUpdated(s.lastUpdated, strings)
|
text = strings.viewHistory,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = strings.updatedAt(updatedLabel),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
// ── NEW: Language switcher ─────────────────────────────────
|
||||||
|
|
||||||
TextButton(onClick = onHistoryClick) {
|
|
||||||
Text(
|
|
||||||
text = strings.viewHistory,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NEW: Language switcher ─────────────────────────────────
|
|
||||||
// Spacer(Modifier.height(8.dp))
|
// Spacer(Modifier.height(8.dp))
|
||||||
// LanguageSwitcherButton(
|
// LanguageSwitcherButton(
|
||||||
// currentTag = lyricist.languageTag,
|
// currentTag = lyricist.languageTag,
|
||||||
// supportedTags = supportedTags,
|
// supportedTags = supportedTags,
|
||||||
// onSelect = { tag -> lyricist.languageTag = tag }
|
// onSelect = { tag -> lyricist.languageTag = tag }
|
||||||
// )
|
// )
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (showCurrencyPicker) {
|
if (showCurrencyPicker) {
|
||||||
CurrencyPickerSheet(
|
CurrencyPickerSheet(
|
||||||
currencies = currencyList,
|
currencies = currencyList,
|
||||||
selectedCurrency = selectedCurrency,
|
selectedCurrency = selectedCurrency,
|
||||||
onSelect = { code ->
|
onSelect = { code ->
|
||||||
vm.setSelectedCurrency(code)
|
vm.setSelectedCurrency(code)
|
||||||
showCurrencyPicker = false
|
showCurrencyPicker = false
|
||||||
},
|
},
|
||||||
isLoading = isLoadingCurrencies,
|
isLoading = isLoadingCurrencies,
|
||||||
onDismiss = { showCurrencyPicker = false }
|
onDismiss = { showCurrencyPicker = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is BalanceState.Error -> {
|
is BalanceState.Error -> {
|
||||||
if (s.staleSats != null) {
|
if (s.staleSats != null) {
|
||||||
Text(
|
Text(
|
||||||
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
|
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
|
||||||
style = MaterialTheme.typography.displayLarge.copy(
|
style = MaterialTheme.typography.displayLarge.copy(
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = strings.sats,
|
text = strings.sats,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
shape = MaterialTheme.shapes.small,
|
shape = MaterialTheme.shapes.small,
|
||||||
modifier = Modifier.fillMaxWidth(0.85f)
|
modifier = Modifier.fillMaxWidth(0.85f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (s.staleSats != null)
|
text = if (s.staleSats != null)
|
||||||
strings.couldNotRefresh
|
strings.couldNotRefresh
|
||||||
else
|
else
|
||||||
strings.couldNotLoadBalance,
|
strings.couldNotLoadBalance,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onContactsClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.People,
|
||||||
|
contentDescription = "Contacts",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNwcClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Cable,
|
||||||
|
contentDescription = "Wallet Connect",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,16 +57,26 @@ import cafe.adriel.lyricist.Lyricist
|
|||||||
import com.bitcointxoko.gudariwallet.MainActivity
|
import com.bitcointxoko.gudariwallet.MainActivity
|
||||||
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
// ── Tab destinations ──────────────────────────────────────────────────────────
|
// ── Tab destinations ──────────────────────────────────────────────────────────
|
||||||
// Note: label and icon are now resolved at the call site via LocalAppStrings,
|
// Note: label and icon are now resolved at the call site via LocalAppStrings,
|
||||||
@@ -81,6 +91,11 @@ private const val ROUTE_HOME = "home"
|
|||||||
private const val ROUTE_SCANNER = "scanner"
|
private const val ROUTE_SCANNER = "scanner"
|
||||||
private const val ROUTE_HISTORY = "history"
|
private const val ROUTE_HISTORY = "history"
|
||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||||
|
private const val ROUTE_NWC = "nwc"
|
||||||
|
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
|
||||||
|
private const val ROUTE_CONTACTS = "contacts"
|
||||||
|
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
@@ -115,11 +130,18 @@ fun WalletScreen(
|
|||||||
repo = vm.repo,
|
repo = vm.repo,
|
||||||
aliasRepo = vm.aliasRepo,
|
aliasRepo = vm.aliasRepo,
|
||||||
paymentCache = vm.paymentCache,
|
paymentCache = vm.paymentCache,
|
||||||
|
contactRepo = vm.contactRepo,
|
||||||
fiatCurrency = vm.selectedCurrency,
|
fiatCurrency = vm.selectedCurrency,
|
||||||
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||||
|
val nwcVm: NwcViewModel = viewModel(
|
||||||
|
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
|
||||||
|
)
|
||||||
|
val contactsVm: ContactsViewModel = viewModel(
|
||||||
|
factory = ContactsViewModel.Factory(vm.contactRepo)
|
||||||
|
)
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
@@ -127,10 +149,18 @@ fun WalletScreen(
|
|||||||
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||||
&& currentRoute != ROUTE_SCANNER
|
&& currentRoute != ROUTE_SCANNER
|
||||||
&& currentRoute != ROUTE_HISTORY
|
&& currentRoute != ROUTE_HISTORY
|
||||||
|
&& currentRoute != ROUTE_NWC
|
||||||
|
&& currentRoute != ROUTE_CONNECTION_DETAIL
|
||||||
|
&& currentRoute != ROUTE_CONTACTS
|
||||||
|
&& currentRoute != ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|| currentRoute == ROUTE_SCANNER
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|
|| currentRoute == ROUTE_NWC
|
||||||
|
|| currentRoute == ROUTE_CONNECTION_DETAIL
|
||||||
|
|| currentRoute == ROUTE_CONTACTS
|
||||||
|
|| currentRoute == ROUTE_CONTACT_DETAIL
|
||||||
|
|
||||||
// ── Notification tap ──────────────────────────────────────────────────────
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
val hash = pendingPaymentHash.value
|
val hash = pendingPaymentHash.value
|
||||||
@@ -192,7 +222,7 @@ fun WalletScreen(
|
|||||||
|
|
||||||
LaunchedEffect(clipboardOffer) {
|
LaunchedEffect(clipboardOffer) {
|
||||||
if (clipboardOffer is ClipboardOfferState.Detected) {
|
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||||
delay(8_000)
|
delay(8_000.milliseconds)
|
||||||
vm.dismissClipboardOffer()
|
vm.dismissClipboardOffer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +232,7 @@ fun WalletScreen(
|
|||||||
|
|
||||||
LaunchedEffect(nfcOffer) {
|
LaunchedEffect(nfcOffer) {
|
||||||
if (nfcOffer is NfcOfferState.Detected) {
|
if (nfcOffer is NfcOfferState.Detected) {
|
||||||
delay(8_000)
|
delay(8_000.milliseconds)
|
||||||
nfcVm.dismissNfcOffer()
|
nfcVm.dismissNfcOffer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,6 +352,14 @@ fun WalletScreen(
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onNwcClick = {
|
||||||
|
navController.navigate(ROUTE_NWC) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContactsClick = {
|
||||||
|
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
|
||||||
|
},
|
||||||
lyricist = lyricist
|
lyricist = lyricist
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -415,6 +453,103 @@ fun WalletScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable(
|
||||||
|
ROUTE_NWC,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) {
|
||||||
|
NwcScreen(
|
||||||
|
viewModel = nwcVm,
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
},
|
||||||
|
onConnectionClick = { pubkey ->
|
||||||
|
navController.navigate("connection_detail/$pubkey") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = ROUTE_CONNECTION_DETAIL,
|
||||||
|
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) { backStackEntry ->
|
||||||
|
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
|
||||||
|
|
||||||
|
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
|
||||||
|
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
|
||||||
|
|
||||||
|
when (val state = connectionState) {
|
||||||
|
is ConnectionDetailState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionDetailState.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ConnectionDetailState.Success -> {
|
||||||
|
ConnectionDetailScreen(
|
||||||
|
connection = state.connection,
|
||||||
|
budgets = state.budgets,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onRevoke = {
|
||||||
|
nwcVm.deleteConnection(pubkey, strings)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
ROUTE_CONTACTS,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) {
|
||||||
|
ContactsScreen(
|
||||||
|
viewModel = contactsVm,
|
||||||
|
onContactClick = { contactId ->
|
||||||
|
navController.navigate("contacts/$contactId") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = ROUTE_CONTACT_DETAIL,
|
||||||
|
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
) { backStackEntry ->
|
||||||
|
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
|
||||||
|
val detailVm: ContactDetailViewModel = viewModel(
|
||||||
|
key = contactId,
|
||||||
|
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
|
||||||
|
)
|
||||||
|
ContactDetailScreen(
|
||||||
|
viewModel = detailVm,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clipboard banner — floats over content, slides in from top ────
|
// ── 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
|
||||||
@@ -20,6 +24,7 @@ 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.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 +33,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,
|
||||||
@@ -124,6 +131,32 @@ class WalletViewModel(
|
|||||||
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
||||||
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
||||||
|
|
||||||
|
val contactForCurrentLnurl: StateFlow<ContactEntity?> =
|
||||||
|
sendVm.sendState
|
||||||
|
.map { state ->
|
||||||
|
val lnurl = (state as? SendState.LnurlReady)?.lnurl
|
||||||
|
?: return@map null
|
||||||
|
contactRepo.findByLnAddress(lnurl)
|
||||||
|
?: contactRepo.findByLnurl(lnurl)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = null
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveContactFromSend(name: String, addressType: PaymentAddressType?, address: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observePaymentEvents()
|
observePaymentEvents()
|
||||||
fetchLightningAddress()
|
fetchLightningAddress()
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.bitcointxoko.gudariwallet.data.NodeAliasServiceRepository
|
|||||||
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||||
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
|
||||||
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
import com.bitcointxoko.gudariwallet.service.NodeAliasService
|
||||||
@@ -24,17 +26,19 @@ import com.bitcointxoko.gudariwallet.ui.send.PaymentPoller
|
|||||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||||
|
|
||||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||||
|
private val lnbitsClient = LNbitsClient(EncryptedSecretStore(app))
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
val secrets = EncryptedSecretStore(app)
|
val secrets = EncryptedSecretStore(app)
|
||||||
val api = LNbitsClient.create(secrets)
|
|
||||||
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
|
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
|
||||||
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
|
val aliasRepo = NodeAliasServiceRepository(nodeAliasSvc)
|
||||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets)
|
val repo = LNbitsWalletRepository(api = lnbitsClient.api, secrets = secrets)
|
||||||
val lnurlCache = LnurlCacheRepository(app)
|
val lnurlCache = LnurlCacheRepository(app)
|
||||||
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
val lnurlScanner = LnurlScanner(repo = repo, lnurlCache = lnurlCache)
|
||||||
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
|
val lnurlPayer = LnurlPayer(repo = repo, scanner = lnurlScanner, cache = lnurlCache)
|
||||||
val poller = PaymentPoller(repo)
|
val poller = PaymentPoller(repo)
|
||||||
val paymentCache = PaymentCacheRepository(app)
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
|
val nwcCache = NwcCacheRepository(app)
|
||||||
|
val contactRepo = ContactRepository(app)
|
||||||
val fiatDataStore = FiatDataStore(app)
|
val fiatDataStore = FiatDataStore(app)
|
||||||
val balancePrefs = BalancePrefsStore(app)
|
val balancePrefs = BalancePrefsStore(app)
|
||||||
val lnAddressStore = LightningAddressStore(app)
|
val lnAddressStore = LightningAddressStore(app)
|
||||||
@@ -62,6 +66,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
|||||||
repo,
|
repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
|
nwcCache = nwcCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
balancePrefs = balancePrefs,
|
balancePrefs = balancePrefs,
|
||||||
balanceVm = balanceVm,
|
balanceVm = balanceVm,
|
||||||
fiatVm = fiatVm,
|
fiatVm = fiatVm,
|
||||||
|
|||||||
@@ -75,11 +75,10 @@ class BalanceViewModel(
|
|||||||
runCatching { repo.getBalance() }
|
runCatching { repo.getBalance() }
|
||||||
.onSuccess { balance ->
|
.onSuccess { balance ->
|
||||||
val sats = balance.sats
|
val sats = balance.sats
|
||||||
val prev = (_balanceState.value as? BalanceState.Success)?.sats
|
when (val prev = (_balanceState.value as? BalanceState.Success)?.sats) {
|
||||||
when {
|
null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
|
||||||
prev == null -> Timber.d("BALANCE [NETWORK ] $sats sats — cold load complete")
|
sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
|
||||||
prev == sats -> Timber.d("BALANCE [NETWORK ] $sats sats — matches cache, no change")
|
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
|
||||||
else -> Timber.d("BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
|
|
||||||
}
|
}
|
||||||
_balanceState.value = BalanceState.Success(
|
_balanceState.value = BalanceState.Success(
|
||||||
sats = sats,
|
sats = sats,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AmountWithFiatColumn(
|
||||||
|
amountSat : Long,
|
||||||
|
isOutgoing : Boolean,
|
||||||
|
amountColor : Color,
|
||||||
|
fiatLine : String?,
|
||||||
|
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||||
|
) {
|
||||||
|
val prefix = if (isOutgoing) "−" else "+"
|
||||||
|
Column(horizontalAlignment = horizontalAlignment) {
|
||||||
|
Text(
|
||||||
|
text = "$prefix$amountSat sats",
|
||||||
|
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = amountColor
|
||||||
|
)
|
||||||
|
if (fiatLine != null) {
|
||||||
|
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
||||||
|
Text(
|
||||||
|
text = fiatLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages screen brightness for QR-display screens.
|
||||||
|
* Returns a [BrightnessController] with [isOn] state and a [toggle] callback.
|
||||||
|
* Restores system brightness unconditionally when it leaves composition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberBrightnessController(): BrightnessController {
|
||||||
|
val window = (LocalActivity.current)?.window
|
||||||
|
var isOn by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Sync side effect: no coroutine needed
|
||||||
|
SideEffect {
|
||||||
|
window?.attributes = window.attributes?.apply {
|
||||||
|
screenBrightness = if (isOn) WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||||
|
else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
window?.attributes = window.attributes?.apply {
|
||||||
|
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No remember needed — BrightnessController is a tiny data class,
|
||||||
|
// and this composable already recomposes only when isOn changes.
|
||||||
|
return BrightnessController(isOn = isOn, toggle = { isOn = !isOn })
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BrightnessController(
|
||||||
|
val isOn : Boolean,
|
||||||
|
val toggle : () -> Unit
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small icon button that copies [value] to the clipboard under [label].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CopyIconButton(
|
||||||
|
label : String,
|
||||||
|
value : String,
|
||||||
|
size : Dp = 32.dp,
|
||||||
|
iconSize : Dp = 16.dp,
|
||||||
|
tint : Color = Color.Unspecified
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value)))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(iconSize),
|
||||||
|
tint = if (tint == Color.Unspecified)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else tint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailRow(
|
||||||
|
label : String,
|
||||||
|
value : String,
|
||||||
|
subtitle : String? = null, // ← from components
|
||||||
|
monospace : Boolean = false, // ← from history
|
||||||
|
copyable : Boolean = false, // ← from history
|
||||||
|
onCopy : (() -> Unit)? = null, // ← from history
|
||||||
|
maxLines : Int = Int.MAX_VALUE,
|
||||||
|
valueColor : Color = Color.Unspecified,
|
||||||
|
textDecoration : TextDecoration? = null,
|
||||||
|
onValueClick : (() -> Unit)? = null,
|
||||||
|
trailingContent: (@Composable () -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val baseStyle = MaterialTheme.typography.bodySmall
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = baseStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(0.35f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(0.65f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = baseStyle.copy(
|
||||||
|
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
|
||||||
|
textDecoration = textDecoration
|
||||||
|
),
|
||||||
|
color = valueColor,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (onValueClick != null) Modifier.clickable(onClick = onValueClick)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copyable) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trailingContent?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailSection(
|
||||||
|
title : String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,642 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.Star
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactDetailScreen(
|
||||||
|
viewModel: ContactDetailViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
|
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
|
||||||
|
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
|
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
val title = when (val s = uiState) {
|
||||||
|
is ContactDetailUiState.Success ->
|
||||||
|
s.data.contact.localAlias
|
||||||
|
?: s.data.contact.displayName
|
||||||
|
?: s.data.contact.name
|
||||||
|
?: "Contact"
|
||||||
|
else -> "Contact"
|
||||||
|
}
|
||||||
|
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (uiState is ContactDetailUiState.Success) {
|
||||||
|
if (isEditing) {
|
||||||
|
// Done button exits edit mode
|
||||||
|
TextButton(onClick = { isEditing = false }) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edit button opens edit mode
|
||||||
|
IconButton(onClick = { isEditing = true }) {
|
||||||
|
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
// FAB only visible in edit mode so users don't accidentally add
|
||||||
|
// addresses while browsing
|
||||||
|
if (uiState is ContactDetailUiState.Success && isEditing) {
|
||||||
|
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add payment address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactDetailUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.NotFound -> {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Contact not found",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactDetailUiState.Success -> {
|
||||||
|
ContactDetailContent(
|
||||||
|
state = state,
|
||||||
|
isEditing = isEditing,
|
||||||
|
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
|
||||||
|
onSetDefault = { viewModel.setDefaultAddress(it) },
|
||||||
|
onEditAlias = { viewModel.openEditAliasSheet() },
|
||||||
|
onDeleteContact = { viewModel.requestDelete() },
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog — mirrors NwcScreen pattern ────────────
|
||||||
|
if (pendingDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
|
title = { Text("Delete contact") },
|
||||||
|
text = { Text("This contact and all their addresses will be permanently removed.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
|
||||||
|
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.cancelDelete() }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAddressDelete?.let { addressId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingAddressDelete = null },
|
||||||
|
title = { Text("Remove address") },
|
||||||
|
text = { Text("This payment address will be permanently removed from the contact.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.removeAddress(addressId)
|
||||||
|
pendingAddressDelete = null
|
||||||
|
}) {
|
||||||
|
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingAddressDelete = null }) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet ─────────────────────────────────────────────────
|
||||||
|
if (showAddAddressSheet) {
|
||||||
|
AddAddressSheet(
|
||||||
|
onSave = { type, address, label, makeDefault ->
|
||||||
|
viewModel.addAddress(type, address, label, makeDefault)
|
||||||
|
viewModel.closeAddAddressSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeAddAddressSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias sheet ──────────────────────────────────────────────────
|
||||||
|
if (showEditAliasSheet) {
|
||||||
|
val currentAlias = (uiState as? ContactDetailUiState.Success)
|
||||||
|
?.data?.contact?.localAlias ?: ""
|
||||||
|
EditAliasSheet(
|
||||||
|
currentAlias = currentAlias,
|
||||||
|
onSave = { newAlias ->
|
||||||
|
viewModel.updateAlias(newAlias)
|
||||||
|
viewModel.closeEditAliasSheet()
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeEditAliasSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main content ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactDetailContent(
|
||||||
|
state: ContactDetailUiState.Success,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onRemoveAddress: (addressId: String) -> Unit,
|
||||||
|
onSetDefault: (PaymentAddressEntity) -> Unit,
|
||||||
|
onEditAlias: () -> Unit,
|
||||||
|
onDeleteContact: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val contact = state.data.contact
|
||||||
|
val addresses = state.data.addresses
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 88.dp) // FAB clearance
|
||||||
|
) {
|
||||||
|
// ── Avatar + name hero ─────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "?"
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
contact.nip05?.let { nip05 ->
|
||||||
|
Text(
|
||||||
|
text = nip05,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses section ─────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Payment addresses") {
|
||||||
|
if (addresses.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No addresses added yet",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
addresses.forEachIndexed { index, addr ->
|
||||||
|
AddressRow(
|
||||||
|
address = addr,
|
||||||
|
isEditing = isEditing,
|
||||||
|
onRemove = { onRemoveAddress(addr.id) },
|
||||||
|
onSetDefault = { onSetDefault(addr) }
|
||||||
|
)
|
||||||
|
if (index < addresses.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
|
||||||
|
contact.about?.let { about ->
|
||||||
|
item {
|
||||||
|
DetailSection(title = "About") {
|
||||||
|
DetailRow(label = "Bio", value = about, maxLines = 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destructive actions (edit mode only) ─────────────────────────
|
||||||
|
if (isEditing) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onEditAlias,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Edit name")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDeleteContact,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Delete contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Address row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddressRow(
|
||||||
|
address: PaymentAddressEntity,
|
||||||
|
isEditing: Boolean,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onSetDefault: () -> Unit,
|
||||||
|
) {
|
||||||
|
val typeLabel = when (address.type) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL.name -> "LNURL"
|
||||||
|
PaymentAddressType.ON_CHAIN.name -> "On-chain"
|
||||||
|
PaymentAddressType.SILENT_PAYMENT.name -> "Silent Payment"
|
||||||
|
PaymentAddressType.BOLT12_OFFER.name -> "BOLT 12 Offer"
|
||||||
|
PaymentAddressType.BIP353.name -> "BIP-353"
|
||||||
|
else -> address.type
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// ── Label + value ─────────────────────────────────────────────
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = typeLabel,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
address.label?.let { lbl ->
|
||||||
|
Text(
|
||||||
|
text = "· $lbl",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (address.isDefault) {
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"default",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.height(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = address.address,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trailing actions ──────────────────────────────────────────
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
// Copy always available
|
||||||
|
CopyIconButton(
|
||||||
|
label = "Copy address",
|
||||||
|
value = address.address,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
// Star + delete only in edit mode
|
||||||
|
if (isEditing) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSetDefault,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (address.isDefault) Icons.Filled.Star
|
||||||
|
else Icons.Outlined.Star,
|
||||||
|
contentDescription = if (address.isDefault) "Default address"
|
||||||
|
else "Set as default",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Remove address",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Add address sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun AddAddressSheet(
|
||||||
|
onSave: (type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val addressFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var selectedType by remember { mutableStateOf(PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf("") }
|
||||||
|
var label by remember { mutableStateOf("") }
|
||||||
|
var makeDefault by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val canSave = address.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { addressFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Add payment address", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Type chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS; address = "" },
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = { selectedType = PaymentAddressType.LNURL; address = "" },
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"Lightning Address" else "LNURL"
|
||||||
|
val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS)
|
||||||
|
"user@domain.com" else "LNURL1..."
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(addressFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional label
|
||||||
|
OutlinedTextField(
|
||||||
|
value = label,
|
||||||
|
onValueChange = { label = it },
|
||||||
|
label = { Text("Label (optional)") },
|
||||||
|
placeholder = { Text("e.g. personal, savings") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set as default toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Set as default",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = makeDefault,
|
||||||
|
onCheckedChange = { makeDefault = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(selectedType, address, label.takeIf { it.isNotBlank() }, makeDefault)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias sheet ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun EditAliasSheet(
|
||||||
|
currentAlias: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var alias by remember { mutableStateOf(currentAlias) }
|
||||||
|
val canSave = alias.isNotBlank()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Edit name", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = alias,
|
||||||
|
onValueChange = { alias = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) onSave(alias)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.cancel) }
|
||||||
|
Button(
|
||||||
|
onClick = { focusManager.clearFocus(); onSave(alias) },
|
||||||
|
enabled = canSave
|
||||||
|
) { Text("Save") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactDetailUiState {
|
||||||
|
data object Loading : ContactDetailUiState()
|
||||||
|
data object NotFound : ContactDetailUiState()
|
||||||
|
data class Success(val data: ContactWithAddresses) : ContactDetailUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactDetailViewModel(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactDetailUiState> = repo.observeContact(contactId)
|
||||||
|
.map { contact ->
|
||||||
|
if (contact == null) ContactDetailUiState.NotFound
|
||||||
|
else ContactDetailUiState.Success(contact)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactDetailUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Delete contact ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _pendingDelete = MutableStateFlow(false)
|
||||||
|
val pendingDelete: StateFlow<Boolean> = _pendingDelete.asStateFlow()
|
||||||
|
|
||||||
|
fun requestDelete() { _pendingDelete.value = true }
|
||||||
|
fun cancelDelete() { _pendingDelete.value = false }
|
||||||
|
|
||||||
|
fun confirmDelete(onDeleted: () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(contactId)
|
||||||
|
_pendingDelete.value = false
|
||||||
|
onDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit alias ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun updateAlias(newAlias: String) {
|
||||||
|
viewModelScope.launch { repo.updateAlias(contactId, newAlias.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment addresses ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun addAddress(type: PaymentAddressType, address: String, label: String?, makeDefault: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = type,
|
||||||
|
address = address.trim(),
|
||||||
|
label = label?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
makeDefault = makeDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAddress(addressId: String) {
|
||||||
|
viewModelScope.launch { repo.removeAddress(addressId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultAddress(address: PaymentAddressEntity) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.addAddress(
|
||||||
|
contactId = contactId,
|
||||||
|
type = PaymentAddressType.valueOf(address.type),
|
||||||
|
address = address.address,
|
||||||
|
label = address.label,
|
||||||
|
makeDefault = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add address sheet state ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showAddAddressSheet = MutableStateFlow(false)
|
||||||
|
val showAddAddressSheet: StateFlow<Boolean> = _showAddAddressSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openAddAddressSheet() { _showAddAddressSheet.value = true }
|
||||||
|
fun closeAddAddressSheet() { _showAddAddressSheet.value = false }
|
||||||
|
|
||||||
|
// ── Edit alias sheet state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _showEditAliasSheet = MutableStateFlow(false)
|
||||||
|
val showEditAliasSheet: StateFlow<Boolean> = _showEditAliasSheet.asStateFlow()
|
||||||
|
|
||||||
|
fun openEditAliasSheet() { _showEditAliasSheet.value = true }
|
||||||
|
fun closeEditAliasSheet() { _showEditAliasSheet.value = false }
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val contactId: String,
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactDetailViewModel(contactId, repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactPickerSheet(
|
||||||
|
contacts: List<ContactWithAddresses>,
|
||||||
|
onContactSelected: (contactId: String) -> Unit,
|
||||||
|
onCreateNew: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var search by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filtered = remember(contacts, search) {
|
||||||
|
if (search.isBlank()) contacts
|
||||||
|
else contacts.filter { c ->
|
||||||
|
val name = c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: ""
|
||||||
|
name.contains(search, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Assign contact", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = search,
|
||||||
|
onValueChange = { search = it },
|
||||||
|
label = { Text("Search") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contact list
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 320.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
items(filtered, key = { it.contact.id }) { item ->
|
||||||
|
val displayName = item.contact.localAlias
|
||||||
|
?: item.contact.displayName
|
||||||
|
?: item.contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = item.addresses.firstOrNull()?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
onContactSelected(item.contact.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.isEmpty() && search.isNotBlank()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "No contacts match \"$search\"",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new contact button
|
||||||
|
HorizontalDivider()
|
||||||
|
TextButton(
|
||||||
|
onClick = onCreateNew,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Create new contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactsScreen(
|
||||||
|
viewModel: ContactsViewModel,
|
||||||
|
onContactClick: (contactId: String) -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var showCreateSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Contacts") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.back
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { showCreateSheet = true }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
is ContactsUiState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ContactsUiState.Success -> {
|
||||||
|
if (state.contacts.isEmpty()) {
|
||||||
|
ContactsEmptyContent(
|
||||||
|
onAdd = { showCreateSheet = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(state.contacts, key = { it.contact.id }) { item ->
|
||||||
|
ContactRow(item, onClick = { onContactClick(item.contact.id) })
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateSheet) {
|
||||||
|
CreateContactSheet(
|
||||||
|
onSave = { name, addressType, address ->
|
||||||
|
viewModel.createContact(name, addressType, address)
|
||||||
|
showCreateSheet = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsEmptyContent(
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tap + to add your first contact",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = onAdd) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Add contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(
|
||||||
|
item: ContactWithAddresses,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val contact = item.contact
|
||||||
|
val displayName = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
|
||||||
|
val primaryAddress = item.addresses
|
||||||
|
.firstOrNull { it.isDefault }
|
||||||
|
?: item.addresses.firstOrNull()
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = primaryAddress?.let { addr ->
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = addr.address,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(40.dp)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = displayName.first().uppercaseChar().toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ContactsUiState {
|
||||||
|
data object Loading : ContactsUiState()
|
||||||
|
data class Success(val contacts: List<ContactWithAddresses>) : ContactsUiState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactsViewModel(
|
||||||
|
private val repo: ContactRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<ContactsUiState> = repo.observeAllContacts()
|
||||||
|
.map { ContactsUiState.Success(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ContactsUiState.Loading
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createContact(
|
||||||
|
name: String,
|
||||||
|
addressType: PaymentAddressType?,
|
||||||
|
address: String?
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
repo.createContact(
|
||||||
|
localAlias = name.trim(),
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContact(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repo.deleteContact(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
ContactsViewModel(repo) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.contacts
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CreateContactSheet(
|
||||||
|
onSave: (name: String, addressType: PaymentAddressType?, address: String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
initialAddressType: PaymentAddressType? = PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
initialAddress: String? = null
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var selectedType by remember { mutableStateOf(initialAddressType ?: PaymentAddressType.LIGHTNING_ADDRESS) }
|
||||||
|
var address by remember { mutableStateOf(initialAddress ?: "") }
|
||||||
|
|
||||||
|
val nameFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val canSave = name.isNotBlank()
|
||||||
|
|
||||||
|
val addressLabel = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "Lightning Address"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL"
|
||||||
|
else -> "Address"
|
||||||
|
}
|
||||||
|
val addressPlaceholder = when (selectedType) {
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS -> "user@domain.com"
|
||||||
|
PaymentAddressType.LNURL -> "LNURL1..."
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { nameFocus.requestFocus() }
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "New contact",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Name field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
placeholder = { Text("Contact name") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(nameFocus)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Address type toggle
|
||||||
|
Text(
|
||||||
|
text = "Payment address (optional)",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("Lightning Address") }
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedType == PaymentAddressType.LNURL,
|
||||||
|
onClick = {
|
||||||
|
selectedType = PaymentAddressType.LNURL
|
||||||
|
address = ""
|
||||||
|
},
|
||||||
|
label = { Text("LNURL") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = address,
|
||||||
|
onValueChange = { address = it },
|
||||||
|
label = { Text(addressLabel) },
|
||||||
|
placeholder = { Text(addressPlaceholder) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
if (canSave) {
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(strings.cancel)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onSave(
|
||||||
|
name.trim(),
|
||||||
|
selectedType.takeIf { address.isNotBlank() },
|
||||||
|
address.trim().takeIf { it.isNotEmpty() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canSave
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -61,14 +62,14 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
// ── Sheet title ───────────────────────────────────────────────────
|
// ── Sheet title ───────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSheetTitle, // ← "Filter payments"
|
text = strings.history.filterSheetTitle, // ← "Filter payments"
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// ── Direction ─────────────────────────────────────────────────────
|
// ── Direction ─────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionDirection, // ← "Direction"
|
text = strings.history.filterSectionDirection, // ← "Direction"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -76,9 +77,9 @@ internal fun FilterBottomSheet(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DirectionFilter.entries.forEach { option ->
|
DirectionFilter.entries.forEach { option ->
|
||||||
val label = when (option) {
|
val label = when (option) {
|
||||||
DirectionFilter.ALL -> strings.filterDirectionAll // ← "All"
|
DirectionFilter.ALL -> strings.history.filterDirectionAll // ← "All"
|
||||||
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
|
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
|
||||||
DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming"
|
DirectionFilter.INCOMING -> strings.history.filterDirectionIncoming // ← "Incoming"
|
||||||
}
|
}
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentFilter.direction == option,
|
selected = currentFilter.direction == option,
|
||||||
@@ -91,7 +92,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Status ────────────────────────────────────────────────────────
|
// ── Status ────────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionStatus, // ← "Status"
|
text = strings.history.filterSectionStatus, // ← "Status"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -104,9 +105,9 @@ internal fun FilterBottomSheet(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (option) {
|
when (option) {
|
||||||
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
|
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
|
||||||
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
|
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
|
||||||
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
|
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ internal fun FilterBottomSheet(
|
|||||||
if (availableTypes.isNotEmpty()) {
|
if (availableTypes.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionType, // ← "Type"
|
text = strings.history.filterSectionType, // ← "Type"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -137,7 +138,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Amount (sats) ─────────────────────────────────────────────────
|
// ── Amount (sats) ─────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionAmount, // ← "Amount (sats)"
|
text = strings.history.filterSectionAmount, // ← "Amount (sats)"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -158,8 +159,8 @@ internal fun FilterBottomSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = minText,
|
value = minText,
|
||||||
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
||||||
label = { Text(strings.filterAmountMin_label) }, // ← "Min"
|
label = { Text(strings.history.filterAmountMin_label) }, // ← "Min"
|
||||||
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
|
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -168,8 +169,8 @@ internal fun FilterBottomSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = maxText,
|
value = maxText,
|
||||||
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
||||||
label = { Text(strings.filterAmountMax_label) }, // ← "Max"
|
label = { Text(strings.history.filterAmountMax_label) }, // ← "Max"
|
||||||
suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
|
suffix = { Text(strings.history.filterAmountSuffix) }, // ← "sats"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -179,7 +180,7 @@ internal fun FilterBottomSheet(
|
|||||||
|
|
||||||
// ── Date ──────────────────────────────────────────────────────────
|
// ── Date ──────────────────────────────────────────────────────────
|
||||||
Text(
|
Text(
|
||||||
text = strings.filterSectionDate, // ← "Date"
|
text = strings.history.filterSectionDate, // ← "Date"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -189,9 +190,9 @@ internal fun FilterBottomSheet(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DatePreset.entries.forEach { preset ->
|
DatePreset.entries.forEach { preset ->
|
||||||
val label = when (preset) {
|
val label = when (preset) {
|
||||||
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
|
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
|
||||||
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
|
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
|
||||||
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
|
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
|
||||||
}
|
}
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = currentFilter.datePreset == preset,
|
selected = currentFilter.datePreset == preset,
|
||||||
@@ -219,8 +220,8 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
currentFilter.minCreatedAt
|
currentFilter.minCreatedAt
|
||||||
?.let { formatEpochShort(it) }
|
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
|
||||||
?: strings.filterDateFrom_label // ← "From"
|
?: strings.history.filterDateFrom_label // ← "From"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text("–", style = MaterialTheme.typography.bodyLarge)
|
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||||
@@ -230,8 +231,8 @@ internal fun FilterBottomSheet(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
currentFilter.maxCreatedAt
|
currentFilter.maxCreatedAt
|
||||||
?.let { formatEpochShort(it) }
|
?.let { formatEpochShort(it, strings.today, strings.yesterday) }
|
||||||
?: strings.filterDateTo_label // ← "To"
|
?: strings.history.filterDateTo_label // ← "To"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,11 +248,11 @@ internal fun FilterBottomSheet(
|
|||||||
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
||||||
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
||||||
showFromPicker = false
|
showFromPicker = false
|
||||||
}) { Text(strings.filterDialogOk) } // ← "OK"
|
}) { Text(strings.history.filterDialogOk) } // ← "OK"
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showFromPicker = false }) {
|
TextButton(onClick = { showFromPicker = false }) {
|
||||||
Text(strings.filterDialogCancel) // ← "Cancel"
|
Text(strings.history.filterDialogCancel) // ← "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { DatePicker(state = pickerState) }
|
) { DatePicker(state = pickerState) }
|
||||||
@@ -269,11 +270,11 @@ internal fun FilterBottomSheet(
|
|||||||
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
||||||
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
||||||
showToPicker = false
|
showToPicker = false
|
||||||
}) { Text(strings.filterDialogOk) } // ← "OK"
|
}) { Text(strings.history.filterDialogOk) } // ← "OK"
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showToPicker = false }) {
|
TextButton(onClick = { showToPicker = false }) {
|
||||||
Text(strings.filterDialogCancel) // ← "Cancel"
|
Text(strings.history.filterDialogCancel) // ← "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { DatePicker(state = pickerState) }
|
) { DatePicker(state = pickerState) }
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.history
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
|
||||||
|
|
||||||
// ── Amount + fiat column ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun AmountWithFiatColumn(
|
|
||||||
amountSat : Long,
|
|
||||||
isOutgoing : Boolean,
|
|
||||||
amountColor : Color,
|
|
||||||
fiatLine : String?,
|
|
||||||
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
|
||||||
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
|
||||||
) {
|
|
||||||
val prefix = if (isOutgoing) "−" else "+"
|
|
||||||
Column(horizontalAlignment = horizontalAlignment) {
|
|
||||||
Text(
|
|
||||||
text = "$prefix$amountSat sats",
|
|
||||||
style = style.copy(fontFamily = FontFamily.Monospace),
|
|
||||||
color = amountColor
|
|
||||||
)
|
|
||||||
if (fiatLine != null) {
|
|
||||||
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
|
||||||
Text(
|
|
||||||
text = fiatLine,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status chip ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun StatusChip(status: String) {
|
|
||||||
val semantic = semanticColors()
|
|
||||||
val strings = LocalAppStrings.current // or however you access Lyricist strings
|
|
||||||
|
|
||||||
data class ChipStyle(
|
|
||||||
val containerColor: Color,
|
|
||||||
val contentColor: Color,
|
|
||||||
val label: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
val style = when (status.lowercase()) {
|
|
||||||
"success", "complete", "paid" -> ChipStyle(
|
|
||||||
semantic.successContainer, semantic.onSuccessContainer, strings.statusSuccess
|
|
||||||
)
|
|
||||||
"pending", "in_flight", "inflight" -> ChipStyle(
|
|
||||||
semantic.warningContainer, semantic.onWarningContainer, strings.statusPending
|
|
||||||
)
|
|
||||||
"failed", "error", "expired" -> ChipStyle(
|
|
||||||
semantic.errorContainer, semantic.onErrorContainer, strings.statusFailed
|
|
||||||
)
|
|
||||||
else -> ChipStyle(
|
|
||||||
MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
strings.statusUnknown,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
color = style.containerColor,
|
|
||||||
contentColor = style.contentColor,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = style.label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Section card wrapper ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DetailSection(
|
|
||||||
title : String,
|
|
||||||
content: @Composable ColumnScope.() -> Unit
|
|
||||||
) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Label / value row ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DetailRow(
|
|
||||||
label : String,
|
|
||||||
value : String,
|
|
||||||
monospace : Boolean = false,
|
|
||||||
copyable : Boolean = false,
|
|
||||||
onCopy : (() -> Unit)? = null,
|
|
||||||
maxLines : Int = Int.MAX_VALUE,
|
|
||||||
valueColor : Color = Color.Unspecified,
|
|
||||||
textDecoration : TextDecoration? = null,
|
|
||||||
onValueClick : (() -> Unit)? = null,
|
|
||||||
trailingContent: (@Composable () -> Unit)? = null
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
|
||||||
.padding(vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(0.35f)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(0.65f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = if (monospace)
|
|
||||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
|
|
||||||
else
|
|
||||||
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
|
|
||||||
color = valueColor,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f, fill = false)
|
|
||||||
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
|
|
||||||
maxLines = maxLines,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
if (copyable) {
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ContentCopy,
|
|
||||||
contentDescription = "Copy $label",
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trailingContent?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── List screen ──────────────────────────────────────────────────────────────
|
// ── List screen ──────────────────────────────────────────────────────────────
|
||||||
@@ -76,6 +77,7 @@ fun HistoryScreen(
|
|||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
val availableTypes by vm.availableTypes.collectAsState()
|
val availableTypes by vm.availableTypes.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val contactNames by vm.contactNames.collectAsState()
|
||||||
|
|
||||||
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
val dismissFilterSheet = { filterSheetVisible = false }
|
val dismissFilterSheet = { filterSheetVisible = false }
|
||||||
@@ -88,12 +90,12 @@ fun HistoryScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(strings.historyTitle) }, // ← "History"
|
title = { Text(strings.history.historyTitle) }, // ← "History"
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historyClose // ← "Close"
|
contentDescription = strings.history.historyClose // ← "Close"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -107,7 +109,7 @@ fun HistoryScreen(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
contentDescription = strings.historySearchPayments // ← "Search payments"
|
contentDescription = strings.history.historySearchPayments // ← "Search payments"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,7 @@ fun HistoryScreen(
|
|||||||
IconButton(onClick = { filterSheetVisible = true }) {
|
IconButton(onClick = { filterSheetVisible = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.FilterList,
|
imageVector = Icons.Default.FilterList,
|
||||||
contentDescription = strings.historyFilterPayments // ← "Filter payments"
|
contentDescription = strings.history.historyFilterPayments // ← "Filter payments"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +192,7 @@ fun HistoryScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Button(onClick = { vm.refresh() }) {
|
Button(onClick = { vm.refresh() }) {
|
||||||
Text(strings.historyRetry) // ← "Retry"
|
Text(strings.history.historyRetry) // ← "Retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ fun HistoryScreen(
|
|||||||
|
|
||||||
if (s.payments.isEmpty()) {
|
if (s.payments.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.historyNoPayments, // ← "No payments yet."
|
text = strings.history.historyNoPayments, // ← "No payments yet."
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@@ -233,6 +235,7 @@ fun HistoryScreen(
|
|||||||
payment = payment,
|
payment = payment,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
contactName = contactNames[payment.checkingId],
|
||||||
onClick = { onPaymentClick(payment) }
|
onClick = { onPaymentClick(payment) }
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -309,13 +312,13 @@ private fun SearchBar(
|
|||||||
IconButton(onClick = onInfoClick) {
|
IconButton(onClick = onInfoClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Info,
|
imageVector = Icons.Default.Info,
|
||||||
contentDescription = strings.historySearchHelp // ← "Search help"
|
contentDescription = strings.history.historySearchHelp // ← "Search help"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions"
|
placeholder = { Text(strings.history.historySearchPlaceholder) }, // ← "Search transactions"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
@@ -336,7 +339,7 @@ private fun SearchBar(
|
|||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ContentPaste,
|
imageVector = Icons.Default.ContentPaste,
|
||||||
contentDescription = strings.historySearchPaste // ← "Paste"
|
contentDescription = strings.history.historySearchPaste // ← "Paste"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -345,7 +348,7 @@ private fun SearchBar(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historySearchClear, // ← "Clear search"
|
contentDescription = strings.history.historySearchClear, // ← "Clear search"
|
||||||
tint = if (query.isNotEmpty()) LocalContentColor.current
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
)
|
)
|
||||||
@@ -381,15 +384,15 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (filter.direction) {
|
when (filter.direction) {
|
||||||
DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
|
DirectionFilter.OUTGOING -> strings.history.filterDirectionOutgoing // ← "Outgoing"
|
||||||
else -> strings.filterDirectionIncoming // ← "Incoming"
|
else -> strings.history.filterDirectionIncoming // ← "Incoming"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearDirection, // ← "Clear direction filter"
|
contentDescription = strings.history.filterClearDirection, // ← "Clear direction filter"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -402,16 +405,16 @@ private fun ActiveFilterChips(
|
|||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
when (s) {
|
when (s) {
|
||||||
StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
|
StatusFilter.COMPLETED -> strings.history.filterStatusCompleted // ← "Completed"
|
||||||
StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
|
StatusFilter.PENDING -> strings.history.filterStatusPending // ← "Pending"
|
||||||
StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
|
StatusFilter.FAILED -> strings.history.filterStatusFailed // ← "Failed"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterRemoveStatus, // ← "Remove status filter"
|
contentDescription = strings.history.filterRemoveStatus, // ← "Remove status filter"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -425,7 +428,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterRemoveType, // ← "Remove type filter"
|
contentDescription = strings.history.filterRemoveType, // ← "Remove type filter"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -434,11 +437,11 @@ private fun ActiveFilterChips(
|
|||||||
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||||
val label = when {
|
val label = when {
|
||||||
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||||
strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats"
|
strings.history.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "X–Y sats"
|
||||||
filter.minAmountSat != null ->
|
filter.minAmountSat != null ->
|
||||||
strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
|
strings.history.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
|
||||||
else ->
|
else ->
|
||||||
strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
|
strings.history.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
@@ -447,7 +450,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearAmount, // ← "Clear amount filter"
|
contentDescription = strings.history.filterClearAmount, // ← "Clear amount filter"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -457,19 +460,19 @@ private fun ActiveFilterChips(
|
|||||||
val maxCreatedAt = filter.maxCreatedAt
|
val maxCreatedAt = filter.maxCreatedAt
|
||||||
if (minCreatedAt != null || maxCreatedAt != null) {
|
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||||
val label = when (filter.datePreset) {
|
val label = when (filter.datePreset) {
|
||||||
DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
|
DatePreset.THIS_WEEK -> strings.history.filterDateThisWeek // ← "This week"
|
||||||
DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
|
DatePreset.THIS_MONTH -> strings.history.filterDateThisMonth // ← "This month"
|
||||||
DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
|
DatePreset.THIS_YEAR -> strings.history.filterDateThisYear // ← "This year"
|
||||||
null -> when {
|
null -> when {
|
||||||
minCreatedAt != null && maxCreatedAt != null ->
|
minCreatedAt != null && maxCreatedAt != null ->
|
||||||
strings.filterDateRange( // ← "X–Y"
|
strings.history.filterDateRange( // ← "X–Y"
|
||||||
formatEpochShort(minCreatedAt),
|
formatEpochShort(minCreatedAt, strings.today, strings.yesterday),
|
||||||
formatEpochShort(maxCreatedAt)
|
formatEpochShort(maxCreatedAt, strings.today, strings.yesterday)
|
||||||
)
|
)
|
||||||
minCreatedAt != null ->
|
minCreatedAt != null ->
|
||||||
strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
|
strings.history.filterDateFrom(formatEpochShort(minCreatedAt, strings.today, strings.yesterday)) // ← "From X"
|
||||||
else ->
|
else ->
|
||||||
strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
|
strings.history.filterDateUntil(formatEpochShort(maxCreatedAt!!, strings.today, strings.yesterday)) // ← "Until X"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -479,7 +482,7 @@ private fun ActiveFilterChips(
|
|||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.filterClearDate, // ← "Clear date filter"
|
contentDescription = strings.history.filterClearDate, // ← "Clear date filter"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -489,11 +492,11 @@ private fun ActiveFilterChips(
|
|||||||
InputChip(
|
InputChip(
|
||||||
selected = true,
|
selected = true,
|
||||||
onClick = onClearSearch,
|
onClick = onClearSearch,
|
||||||
label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
|
label = { Text(strings.history.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = strings.historyClearSearch, // ← "Clear search"
|
contentDescription = strings.history.historyClearSearch, // ← "Clear search"
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isFailedStatus
|
||||||
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
import com.bitcointxoko.gudariwallet.util.isPendingStatus
|
||||||
|
|
||||||
internal fun formatEpochShort(epoch: Long): String =
|
|
||||||
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
|
|
||||||
.withZone(java.time.ZoneId.systemDefault())
|
|
||||||
.format(java.time.Instant.ofEpochSecond(epoch))
|
|
||||||
|
|
||||||
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
|
||||||
val formatter = java.time.format.DateTimeFormatter
|
|
||||||
.ofPattern("dd MMM yyyy, HH:mm")
|
|
||||||
.withZone(java.time.ZoneId.systemDefault())
|
|
||||||
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
|
|
||||||
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
|
|
||||||
}
|
|
||||||
// Fallback: only reached for payments not yet written through Room
|
|
||||||
return runCatching {
|
|
||||||
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
|
|
||||||
}.recoverCatching {
|
|
||||||
formatter.format(java.time.OffsetDateTime.parse(rawTime))
|
|
||||||
}.getOrDefault(rawTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isPendingStatus(status: String): Boolean =
|
|
||||||
status.lowercase() in setOf("pending", "in_flight", "inflight")
|
|
||||||
|
|
||||||
internal fun isFailedStatus(status: String): Boolean =
|
|
||||||
status.lowercase() in setOf("failed", "error", "expired")
|
|
||||||
|
|
||||||
// ── Composable helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.ContactRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
|
||||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactEntity
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
@@ -21,6 +25,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "HistoryViewModel"
|
private const val TAG = "HistoryViewModel"
|
||||||
|
|
||||||
@@ -29,6 +34,7 @@ class HistoryViewModel(
|
|||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
val fiatCurrency : StateFlow<String?>,
|
val fiatCurrency : StateFlow<String?>,
|
||||||
val fiatSatsPerUnit : StateFlow<Double?>
|
val fiatSatsPerUnit : StateFlow<Double?>
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -113,6 +119,7 @@ class HistoryViewModel(
|
|||||||
?.payments?.firstOrNull { it.checkingId == checkingId }
|
?.payments?.firstOrNull { it.checkingId == checkingId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Room-backed query results ──────────────────────────────────────────────
|
// ── Room-backed query results ──────────────────────────────────────────────
|
||||||
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
private val _roomResults = MutableStateFlow<List<PaymentRecord>?>(null)
|
||||||
|
|
||||||
@@ -150,13 +157,100 @@ class HistoryViewModel(
|
|||||||
else list.filter { it.extra?.tag in f.types }
|
else list.filter { it.extra?.tag in f.types }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact name map ─────────────────────────────────────────────────────
|
||||||
|
// Maps checkingId → display name for any payment that has a linked contact.
|
||||||
|
// Rebuilt whenever the underlying tx_contact_links or contacts tables change.
|
||||||
|
val contactNames: StateFlow<Map<String, String>> =
|
||||||
|
contactRepo.observeAllPaymentContactNames()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyMap()
|
||||||
|
)
|
||||||
|
// Maps contactId → display name (for filter chips)
|
||||||
|
val contactDisplayNames: StateFlow<Map<String, String>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.map { contacts ->
|
||||||
|
contacts.associate { c ->
|
||||||
|
c.contact.id to (c.contact.localAlias
|
||||||
|
?: c.contact.displayName
|
||||||
|
?: c.contact.name
|
||||||
|
?: "Unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyMap()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Contact assignment ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun contactsForPayment(paymentHash: String): StateFlow<List<ContactEntity>> =
|
||||||
|
contactRepo.observeContactsForPayment(paymentHash)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allContacts(): StateFlow<List<ContactWithAddresses>> =
|
||||||
|
contactRepo.observeAllContacts()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun assignContact(paymentHash: String, contactId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepo.linkPaymentToContact(paymentHash, contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unassignContact(paymentHash: String, contactId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepo.unlinkPaymentFromContact(paymentHash, contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveContactAndAssign(
|
||||||
|
paymentHash : String,
|
||||||
|
name : String,
|
||||||
|
addressType : PaymentAddressType?,
|
||||||
|
address : String?
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val lnAddress = address?.takeIf { addressType == PaymentAddressType.LIGHTNING_ADDRESS }
|
||||||
|
val lnUrl = address?.takeIf { addressType == PaymentAddressType.LNURL }
|
||||||
|
val contact = contactRepo.createContact(
|
||||||
|
localAlias = name,
|
||||||
|
lnAddress = lnAddress,
|
||||||
|
lnUrl = lnUrl
|
||||||
|
)
|
||||||
|
contactRepo.linkPaymentToContact(paymentHash, contact.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleContactFilter(contactId: String) {
|
||||||
|
_filter.update { f ->
|
||||||
|
val updated = if (contactId in f.contactIds)
|
||||||
|
f.contactIds - contactId else f.contactIds + contactId
|
||||||
|
f.copy(contactIds = updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun clearContactFilter() {
|
||||||
|
_filter.update { it.copy(contactIds = emptySet()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
init {
|
init {
|
||||||
syncManager.init()
|
syncManager.init()
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(
|
combine(
|
||||||
_filter.map { it.searchQuery }.debounce(300),
|
_filter.map { it.searchQuery }.debounce(300.milliseconds),
|
||||||
_filter
|
_filter
|
||||||
) { _, f -> f }
|
) { _, f -> f }
|
||||||
.collect { f ->
|
.collect { f ->
|
||||||
@@ -164,11 +258,50 @@ class HistoryViewModel(
|
|||||||
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
Timber.d("FILTER [IN-MEMORY] filter=default → using cached state")
|
||||||
_roomResults.value = null
|
_roomResults.value = null
|
||||||
} else {
|
} else {
|
||||||
Timber.d("FILTER [ROOM ] q=\"${f.searchQuery}\" " +
|
// ── Room query path ───────────────────────────────────
|
||||||
"statuses=${f.statuses} dir=${f.direction} types=${f.types} " +
|
val baseResults = paymentCache.queryAll(f.searchQuery, f)
|
||||||
"amt=${f.minAmountSat}–${f.maxAmountSat} " +
|
.toMutableList()
|
||||||
"date=${f.minCreatedAt}–${f.maxCreatedAt}")
|
|
||||||
_roomResults.value = paymentCache.queryAll(f.searchQuery, f)
|
// ── Supplemental contact search ───────────────────────
|
||||||
|
// 1. Text query may match contact names not in payment_records
|
||||||
|
if (f.searchQuery.isNotBlank()) {
|
||||||
|
val contactCheckingIds = contactRepo
|
||||||
|
.findCheckingIdsByContactName(f.searchQuery)
|
||||||
|
if (contactCheckingIds.isNotEmpty()) {
|
||||||
|
val contactPayments = paymentCache
|
||||||
|
.queryByCheckingIds(contactCheckingIds, f)
|
||||||
|
// Merge without duplicates
|
||||||
|
val existingIds = baseResults.map { it.checkingId }.toSet()
|
||||||
|
baseResults += contactPayments
|
||||||
|
.filter { it.checkingId !in existingIds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Explicit contact ID filter
|
||||||
|
if (f.contactIds.isNotEmpty()) {
|
||||||
|
val contactCheckingIds = contactRepo
|
||||||
|
.findCheckingIdsByContactIds(f.contactIds.toList())
|
||||||
|
if (contactCheckingIds.isNotEmpty()) {
|
||||||
|
// Fetch ALL payments for these contacts, WITH the full filter applied
|
||||||
|
val contactFiltered = paymentCache.queryByCheckingIds(
|
||||||
|
checkingIds = contactCheckingIds,
|
||||||
|
filter = f
|
||||||
|
)
|
||||||
|
_roomResults.value = contactFiltered
|
||||||
|
.also {
|
||||||
|
Timber.d("FILTER [ROOM+CONTACT] contacts=${f.contactIds} → ${it.size} results")
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
} else {
|
||||||
|
// Selected contacts have no payments — return empty
|
||||||
|
_roomResults.value = emptyList()
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_roomResults.value = baseResults.also {
|
||||||
|
Timber.d("FILTER [ROOM] q=\"${f.searchQuery}\" → ${it.size} results")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +320,7 @@ class HistoryViewModelFactory(
|
|||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
private val aliasRepo : NodeAliasRepository,
|
private val aliasRepo : NodeAliasRepository,
|
||||||
private val paymentCache : PaymentCacheRepository,
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val contactRepo : ContactRepository,
|
||||||
private val fiatCurrency : StateFlow<String?>,
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
private val fiatSatsPerUnit: StateFlow<Double?>
|
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||||
) : ViewModelProvider.Factory {
|
) : ViewModelProvider.Factory {
|
||||||
@@ -196,6 +330,7 @@ class HistoryViewModelFactory(
|
|||||||
repo = repo,
|
repo = repo,
|
||||||
aliasRepo = aliasRepo,
|
aliasRepo = aliasRepo,
|
||||||
paymentCache = paymentCache,
|
paymentCache = paymentCache,
|
||||||
|
contactRepo = contactRepo,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
) as T
|
) as T
|
||||||
|
|||||||
+218
-83
@@ -6,24 +6,29 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.ArrowDownward
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.outlined.ContentCopy
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -33,8 +38,10 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
@@ -42,14 +49,24 @@ import androidx.compose.ui.platform.LocalClipboard
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType
|
||||||
import com.bitcointxoko.gudariwallet.ui.DetailState
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.StatusChip
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.ContactPickerSheet
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.contacts.CreateContactSheet
|
||||||
import com.bitcointxoko.gudariwallet.util.feePpm
|
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── Detail screen ────────────────────────────────────────────────────────────
|
// ── Detail screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,8 +75,8 @@ fun PaymentDetailScreen(
|
|||||||
vm : HistoryViewModel,
|
vm : HistoryViewModel,
|
||||||
onBack : () -> Unit
|
onBack : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val detailState by vm.detailState.collectAsState()
|
val detailState by vm.detailState.collectAsState()
|
||||||
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
|
||||||
@@ -71,14 +88,31 @@ fun PaymentDetailScreen(
|
|||||||
val detail = s.detail.details
|
val detail = s.detail.details
|
||||||
when {
|
when {
|
||||||
detail == null -> payment
|
detail == null -> payment
|
||||||
else -> detail.copy(
|
else -> detail.copy(
|
||||||
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||||
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DetailState.Partial -> s.record
|
is DetailState.Partial -> s.record
|
||||||
else -> payment
|
else -> payment
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contact state ─────────────────────────────────────────────────────────
|
||||||
|
val linkedContacts by remember(payment.checkingId) {
|
||||||
|
vm.contactsForPayment(payment.checkingId)
|
||||||
|
}.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val allContacts by remember { vm.allContacts() }.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var showContactPicker by remember { mutableStateOf(false) }
|
||||||
|
var showCreateContact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Best-effort lnAddress to pre-fill CreateContactSheet:
|
||||||
|
// prefer extra.comment if it looks like a Lightning Address, else null
|
||||||
|
val paymentLnAddress = remember(enriched) {
|
||||||
|
enriched.extra?.comment
|
||||||
|
?.takeIf { it.contains("@") && !it.contains(" ") }
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -86,15 +120,15 @@ fun PaymentDetailScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment"
|
if (payment.isOutgoing) strings.history.detailOutgoingTitle
|
||||||
else strings.detailIncomingTitle // ← "Incoming payment"
|
else strings.history.detailIncomingTitle
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = strings.detailBack // ← "Back"
|
contentDescription = strings.history.detailBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,8 +138,8 @@ fun PaymentDetailScreen(
|
|||||||
when (detailState) {
|
when (detailState) {
|
||||||
is DetailState.Loading -> {
|
is DetailState.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) { CircularProgressIndicator() }
|
) { CircularProgressIndicator() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +150,7 @@ fun PaymentDetailScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.detailCouldNotLoad, // ← "Could not load full details"
|
text = strings.history.detailCouldNotLoad,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
@@ -125,33 +159,78 @@ fun PaymentDetailScreen(
|
|||||||
PaymentDetailContent(
|
PaymentDetailContent(
|
||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(payment.checkingId, contactId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
PaymentDetailContent(
|
PaymentDetailContent(
|
||||||
payment = enriched,
|
payment = enriched,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
fiatSatsPerUnit = fiatSatsPerUnit,
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
modifier = Modifier.padding(padding)
|
linkedContacts = linkedContacts,
|
||||||
|
onAssignContact = { showContactPicker = true },
|
||||||
|
onUnassignContact = { contactId ->
|
||||||
|
vm.unassignContact(payment.checkingId, contactId)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contact picker sheet ──────────────────────────────────────────────────
|
||||||
|
if (showContactPicker) {
|
||||||
|
ContactPickerSheet(
|
||||||
|
contacts = allContacts,
|
||||||
|
onContactSelected = { contactId ->
|
||||||
|
vm.assignContact(payment.checkingId, contactId)
|
||||||
|
showContactPicker = false
|
||||||
|
},
|
||||||
|
onCreateNew = {
|
||||||
|
showContactPicker = false
|
||||||
|
showCreateContact = true
|
||||||
|
},
|
||||||
|
onDismiss = { showContactPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create & assign contact sheet ─────────────────────────────────────────
|
||||||
|
if (showCreateContact) {
|
||||||
|
val addressType = if (paymentLnAddress != null)
|
||||||
|
PaymentAddressType.LIGHTNING_ADDRESS else PaymentAddressType.LIGHTNING_ADDRESS
|
||||||
|
|
||||||
|
CreateContactSheet(
|
||||||
|
initialAddressType = addressType,
|
||||||
|
initialAddress = paymentLnAddress,
|
||||||
|
onSave = { name, type, address ->
|
||||||
|
vm.saveContactAndAssign(payment.checkingId, name, type, address)
|
||||||
|
showCreateContact = false
|
||||||
|
},
|
||||||
|
onDismiss = { showCreateContact = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Detail content ───────────────────────────────────────────────────────────
|
// ── Detail content ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaymentDetailContent(
|
private fun PaymentDetailContent(
|
||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
modifier : Modifier = Modifier
|
linkedContacts : List<com.bitcointxoko.gudariwallet.data.db.ContactEntity>,
|
||||||
|
onAssignContact : () -> Unit,
|
||||||
|
onUnassignContact : (contactId: String) -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -173,45 +252,42 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// ── Hero amount ──────────────────────────────────────────────────────
|
// ── Hero amount ────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
else Icons.Default.ArrowDownward,
|
else Icons.Default.ArrowDownward,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = amountColor,
|
tint = amountColor,
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(36.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
AmountWithFiatColumn(
|
AmountWithFiatColumn(
|
||||||
amountSat = payment.amountSat,
|
amountSat = payment.amountSat,
|
||||||
isOutgoing = payment.isOutgoing,
|
isOutgoing = payment.isOutgoing,
|
||||||
amountColor = amountColor,
|
amountColor = amountColor,
|
||||||
fiatLine = heroFiat,
|
fiatLine = heroFiat,
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
)
|
)
|
||||||
if (payment.feeSat > 0) {
|
if (payment.feeSat > 0) {
|
||||||
val ppm = feePpm(
|
val ppm = feePpm(
|
||||||
amountMsat = kotlin.math.abs(payment.amountMsat),
|
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||||
feeMsat = kotlin.math.abs(payment.feeMsat)
|
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||||
)
|
)
|
||||||
// Build the optional parts separately so the lambda stays clean
|
|
||||||
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
|
||||||
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
val ppmPart = if (ppm != null) " · $ppm ppm" else ""
|
||||||
val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
|
val feeLabel = strings.history.detailFeeLabel(payment.feeSat, fiatPart, ppmPart)
|
||||||
payment.feeSat, fiatPart, ppmPart
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = feeLabel,
|
text = feeLabel,
|
||||||
@@ -225,43 +301,118 @@ private fun PaymentDetailContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Basic info ───────────────────────────────────────────────────────
|
// ── Contact section ────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionDetails) { // ← "Details"
|
DetailSection(title = "Contact") {
|
||||||
DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date"
|
if (linkedContacts.isEmpty()) {
|
||||||
|
// No contact linked — show assign button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onAssignContact,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Assign contact")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show each linked contact with a remove button
|
||||||
|
linkedContacts.forEach { contact ->
|
||||||
|
val name = contact.localAlias
|
||||||
|
?: contact.displayName
|
||||||
|
?: contact.name
|
||||||
|
?: "Unknown"
|
||||||
|
DetailRow(
|
||||||
|
label = "Contact",
|
||||||
|
value = name,
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { onUnassignContact(contact.id) },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Remove contact",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Allow assigning additional contacts
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onAssignContact) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add another",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic info ─────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.history.detailSectionDetails) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
strings.detailRowMemo, // ← "Memo"
|
strings.history.detailRowDate,
|
||||||
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—"
|
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
DetailRow(
|
||||||
|
strings.history.detailRowMemo,
|
||||||
|
payment.memo?.takeIf { it.isNotBlank() } ?: strings.history.detailRowMemoEmpty
|
||||||
)
|
)
|
||||||
if (payment.extra?.tag != null) {
|
if (payment.extra?.tag != null) {
|
||||||
DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type"
|
DetailRow(strings.history.detailRowType, payment.extra.tag)
|
||||||
}
|
}
|
||||||
if (payment.extra?.comment?.isNotBlank() == true) {
|
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||||
DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment"
|
DetailRow(strings.history.detailRowComment, payment.extra.comment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Success action (LNURL) ───────────────────────────────────────────
|
// ── Success action (LNURL) ─────────────────────────────────────────────
|
||||||
payment.extra?.successAction?.let { action ->
|
payment.extra?.successAction?.let { action ->
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action"
|
DetailSection(title = strings.history.detailSectionSuccessAction) {
|
||||||
action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message"
|
action.message?.let { DetailRow(strings.history.detailSuccessMessage, it) }
|
||||||
action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL"
|
action.url?.let { DetailRow(strings.history.detailSuccessUrl, it) }
|
||||||
action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description"
|
action.description?.let { DetailRow(strings.history.detailSuccessDescription, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Technical ────────────────────────────────────────────────────────
|
// ── Technical ─────────────────────────────────────────────────────────
|
||||||
item {
|
item {
|
||||||
DetailSection(title = strings.detailSectionTechnical) { // ← "Technical"
|
DetailSection(title = strings.history.detailSectionTechnical) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowPaymentHash, // ← "Payment Hash"
|
label = strings.history.detailRowPaymentHash,
|
||||||
value = payment.paymentHash,
|
value = payment.paymentHash,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
||||||
@@ -271,11 +422,11 @@ private fun PaymentDetailContent(
|
|||||||
)
|
)
|
||||||
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowPreimage, // ← "Preimage"
|
label = strings.history.detailRowPreimage,
|
||||||
value = payment.preimage,
|
value = payment.preimage,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
||||||
@@ -288,7 +439,7 @@ private fun PaymentDetailContent(
|
|||||||
val ambossUrl = "https://amboss.space/node/$pubkey"
|
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||||
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowDestination, // ← "Destination"
|
label = strings.history.detailRowDestination,
|
||||||
value = label,
|
value = label,
|
||||||
valueColor = MaterialTheme.colorScheme.primary,
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||||
@@ -296,34 +447,18 @@ private fun PaymentDetailContent(
|
|||||||
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(
|
CopyIconButton(label = "node_id", value = pubkey)
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
clipboard.setClipEntry(
|
|
||||||
ClipEntry(ClipData.newPlainText("node_id", pubkey))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(32.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.ContentCopy,
|
|
||||||
contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!payment.bolt11.isNullOrBlank()) {
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
DetailRow(
|
DetailRow(
|
||||||
label = strings.detailRowBolt11, // ← "BOLT11"
|
label = strings.history.detailRowBolt11,
|
||||||
value = payment.bolt11,
|
value = payment.bolt11,
|
||||||
monospace = true,
|
monospace = true,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
onCopy = {
|
onCopy = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
clipboard.setClipEntry(
|
clipboard.setClipEntry(
|
||||||
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ data class PaymentFilter(
|
|||||||
val maxAmountSat : Long? = null,
|
val maxAmountSat : Long? = null,
|
||||||
val minCreatedAt : Long? = null,
|
val minCreatedAt : Long? = null,
|
||||||
val maxCreatedAt : Long? = null,
|
val maxCreatedAt : Long? = null,
|
||||||
val datePreset : DatePreset? = null
|
val datePreset : DatePreset? = null,
|
||||||
|
val contactIds : Set<String> = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
||||||
@@ -29,12 +30,16 @@ val PaymentFilter.isActive: Boolean
|
|||||||
|| maxAmountSat != null
|
|| maxAmountSat != null
|
||||||
|| minCreatedAt != null
|
|| minCreatedAt != null
|
||||||
|| maxCreatedAt != null
|
|| maxCreatedAt != null
|
||||||
|
|| contactIds.isNotEmpty()
|
||||||
|
|
||||||
fun PaymentFilter.needsRoomQuery(): Boolean =
|
fun PaymentFilter.needsRoomQuery(): Boolean =
|
||||||
searchQuery.isNotBlank() ||
|
searchQuery.isNotBlank() ||
|
||||||
statuses.isNotEmpty() ||
|
statuses.isNotEmpty() ||
|
||||||
minAmountSat != null || maxAmountSat != null ||
|
minAmountSat != null ||
|
||||||
minCreatedAt != null || maxCreatedAt != null
|
maxAmountSat != null ||
|
||||||
|
minCreatedAt != null ||
|
||||||
|
maxCreatedAt != null ||
|
||||||
|
contactIds.isNotEmpty()
|
||||||
|
|
||||||
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
/** Resolves a [DatePreset] to a (epochSecondMin, epochSecondMax) pair. */
|
||||||
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
fun DatePreset.toEpochSecondRange(): Pair<Long, Long> {
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.AmountWithFiatColumn
|
||||||
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatTimestamp
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isFailedStatus
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isPendingStatus
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PaymentRow(
|
internal fun PaymentRow(
|
||||||
payment : PaymentRecord,
|
payment : PaymentRecord,
|
||||||
fiatCurrency : String?,
|
fiatCurrency : String?,
|
||||||
fiatSatsPerUnit : Double?,
|
fiatSatsPerUnit : Double?,
|
||||||
|
contactName : String? = null,
|
||||||
onClick : () -> Unit
|
onClick : () -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -57,8 +62,8 @@ internal fun PaymentRow(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent"
|
contentDescription = if (payment.isOutgoing) strings.history.paymentRowSentCD // ← "Sent"
|
||||||
else strings.paymentRowReceivedCD, // ← "Received"
|
else strings.history.paymentRowReceivedCD, // ← "Received"
|
||||||
tint = amountColor,
|
tint = amountColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
@@ -66,15 +71,16 @@ internal fun PaymentRow(
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = payment.memo?.takeIf { it.isNotBlank() }
|
text = contactName // ← contact name first
|
||||||
?: strings.paymentRowNoMemo, // ← "No memo"
|
?: payment.memo?.takeIf { it.isNotBlank() }
|
||||||
|
?: strings.history.paymentRowNoMemo,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
||||||
formatTimestamp(payment.createdAt, payment.time)
|
formatTimestamp(payment.createdAt, payment.time, strings.today, strings.yesterday)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
@@ -82,8 +88,8 @@ internal fun PaymentRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
val statusLabel = when {
|
val statusLabel = when {
|
||||||
isPending -> strings.paymentRowPending
|
isPending -> strings.history.paymentRowPending
|
||||||
isFailed -> strings.paymentRowFailed
|
isFailed -> strings.history.paymentRowFailed
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val statusColor = when {
|
val statusColor = when {
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ class PaymentSyncManager(
|
|||||||
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
|
val reachedEnd = page.size < WalletConstants.PAYMENTS_PAGE_SIZE
|
||||||
|
|
||||||
if (reachedOverlap || reachedEnd) {
|
if (reachedOverlap || reachedEnd) {
|
||||||
Timber.d("PAYMENTS [SYNC DONE ] $totalNew new payment(s) across $pagesChecked page(s) — " +
|
Timber.d("null%s", if (reachedOverlap) "stopped at known record" else "reached end of history")
|
||||||
if (reachedOverlap) "stopped at known record" else "reached end of history")
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,45 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
|
|||||||
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoTitle, // ← "What can I search for?"
|
text = strings.history.searchInfoTitle, // ← "What can I search for?"
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoSubtitle, // ← "Type any part of the following…"
|
text = strings.history.searchInfoSubtitle, // ← "Type any part of the following…"
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoMemoTitle, // ← "Memo"
|
title = strings.history.searchInfoMemoTitle, // ← "Memo"
|
||||||
description = strings.searchInfoMemoDesc
|
description = strings.history.searchInfoMemoDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoInvoiceTitle, // ← "Invoice"
|
title = strings.history.searchInfoInvoiceTitle, // ← "Invoice"
|
||||||
description = strings.searchInfoInvoiceDesc
|
description = strings.history.searchInfoInvoiceDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoHashTitle, // ← "Hash"
|
title = strings.history.searchInfoHashTitle, // ← "Hash"
|
||||||
description = strings.searchInfoHashDesc
|
description = strings.history.searchInfoHashDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoPreimageTitle, // ← "Preimage"
|
title = strings.history.searchInfoPreimageTitle, // ← "Preimage"
|
||||||
description = strings.searchInfoPreimageDesc
|
description = strings.history.searchInfoPreimageDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoNodeAliasTitle, // ← "Node alias"
|
title = strings.history.searchInfoNodeAliasTitle, // ← "Node alias"
|
||||||
description = strings.searchInfoNodeAliasDesc
|
description = strings.history.searchInfoNodeAliasDesc
|
||||||
)
|
)
|
||||||
SearchInfoItem(
|
SearchInfoItem(
|
||||||
title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
|
title = strings.history.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
|
||||||
description = strings.searchInfoNodeIdDesc
|
description = strings.history.searchInfoNodeIdDesc
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.searchInfoFooter, // ← "You can paste a full value…"
|
text = strings.history.searchInfoFooter, // ← "You can paste a full value…"
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.lock
|
||||||
|
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Fingerprint
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.LockStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BiometricLockScreen(onUnlocked: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as FragmentActivity
|
||||||
|
val strings = LocalAppStrings.current.lock
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
showBiometricPrompt(activity, onUnlocked, strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.appName,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.authenticateToContinue,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { showBiometricPrompt(activity, onUnlocked, strings) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(strings.unlockButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPrompt(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
onUnlocked: () -> Unit,
|
||||||
|
strings: LockStrings
|
||||||
|
) {
|
||||||
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
|
||||||
|
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
onUnlocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val prompt = BiometricPrompt(activity, executor, callback)
|
||||||
|
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(strings.biometricPromptTitle)
|
||||||
|
.setSubtitle(strings.biometricPromptSubtitle)
|
||||||
|
.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||||
|
or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
prompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
@@ -77,13 +77,12 @@ class NdefHceService : HostApduService() {
|
|||||||
|
|
||||||
val ins = apdu[1]
|
val ins = apdu[1]
|
||||||
|
|
||||||
return when {
|
return when (ins) {
|
||||||
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
|
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
|
||||||
ins == 0xA4.toByte() -> handleSelect(apdu)
|
0xA4.toByte() -> handleSelect(apdu)
|
||||||
|
|
||||||
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
|
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
|
||||||
ins == 0xB0.toByte() -> handleReadBinary(apdu)
|
0xB0.toByte() -> handleReadBinary(apdu)
|
||||||
|
|
||||||
else -> SW_UNKNOWN
|
else -> SW_UNKNOWN
|
||||||
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
|
}.also { Timber.d("APDU OUT: ${it.toHex()}") }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -83,7 +82,7 @@ class NfcViewModel : ViewModel() {
|
|||||||
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
|
intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawMessages != null && rawMessages.isNotEmpty()) {
|
if (!rawMessages.isNullOrEmpty()) {
|
||||||
val message = rawMessages[0] as NdefMessage
|
val message = rawMessages[0] as NdefMessage
|
||||||
val uri = extractUri(message)
|
val uri = extractUri(message)
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -98,7 +97,7 @@ class NfcViewModel : ViewModel() {
|
|||||||
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
|
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
|
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
|
||||||
}
|
}
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CalendarToday
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
private class AddConnectionState {
|
||||||
|
var description by mutableStateOf("")
|
||||||
|
var selectedPermissions by mutableStateOf(
|
||||||
|
ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()
|
||||||
|
)
|
||||||
|
var neverExpires by mutableStateOf(false)
|
||||||
|
var expiry: LocalDateTime by mutableStateOf(
|
||||||
|
LocalDateTime.now().plusDays(30).withSecond(0).withNano(0)
|
||||||
|
)
|
||||||
|
var showDatePicker by mutableStateOf(false)
|
||||||
|
var showTimePicker by mutableStateOf(false)
|
||||||
|
var nextBudgetId by mutableIntStateOf(0)
|
||||||
|
var budgets by mutableStateOf(emptyList<BudgetDraft>())
|
||||||
|
|
||||||
|
// Converts the LocalDateTime expiry to a Unix epoch in seconds
|
||||||
|
fun expiryEpochSeconds(): Long =
|
||||||
|
expiry.atZone(ZoneId.systemDefault()).toEpochSecond()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NwcPermission.displayLabel(strings: AppStrings): String = when (serverKey) {
|
||||||
|
"pay" -> strings.nwc.permPay
|
||||||
|
"invoice" -> strings.nwc.permInvoice
|
||||||
|
"lookup" -> strings.nwc.permLookup
|
||||||
|
"history" -> strings.nwc.permHistory
|
||||||
|
"balance" -> strings.nwc.permBalance
|
||||||
|
"info" -> strings.nwc.permInfo
|
||||||
|
else -> serverKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun NwcAddConnectionSheet(
|
||||||
|
isCreating : Boolean,
|
||||||
|
onCreate : (
|
||||||
|
description : String,
|
||||||
|
permissions : List<String>,
|
||||||
|
expiresAt : Long,
|
||||||
|
budgets : List<NwcNewBudget>
|
||||||
|
) -> Unit,
|
||||||
|
onDismiss : () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val state = remember { AddConnectionState() }
|
||||||
|
|
||||||
|
|
||||||
|
if (state.showDatePicker) {
|
||||||
|
val initialMillis = state.expiry
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
val datePickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = initialMillis
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { state.showDatePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
|
val picked = java.time.Instant
|
||||||
|
.ofEpochMilli(millis)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDate()
|
||||||
|
state.expiry = state.expiry
|
||||||
|
.withYear(picked.year)
|
||||||
|
.withMonth(picked.monthValue)
|
||||||
|
.withDayOfMonth(picked.dayOfMonth)
|
||||||
|
}
|
||||||
|
state.showDatePicker = false
|
||||||
|
}) { Text(strings.nwc.ok) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { state.showDatePicker = false }) { Text(strings.nwc.cancel) }
|
||||||
|
}
|
||||||
|
) { DatePicker(state = datePickerState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.showTimePicker) {
|
||||||
|
val timePickerState = rememberTimePickerState(
|
||||||
|
initialHour = state.expiry.hour,
|
||||||
|
initialMinute = state.expiry.minute,
|
||||||
|
is24Hour = true
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { state.showTimePicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
state.expiry = state.expiry
|
||||||
|
.withHour(timePickerState.hour)
|
||||||
|
.withMinute(timePickerState.minute)
|
||||||
|
.withSecond(0)
|
||||||
|
.withNano(0)
|
||||||
|
state.showTimePicker = false
|
||||||
|
}) { Text(strings.nwc.ok) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { state.showTimePicker = false }) { Text(strings.nwc.cancel) }
|
||||||
|
},
|
||||||
|
text = { TimePicker(state = timePickerState) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.newConnection,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Description ──────────────────────────────────────────────────
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.description,
|
||||||
|
onValueChange = { state.description = it },
|
||||||
|
label = { Text(strings.nwc.labelHint) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Expiry ───────────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = strings.nwc.expires, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (!state.neverExpires) {
|
||||||
|
val dateLabel = remember(state.expiry) {
|
||||||
|
DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.getDefault()).format(state.expiry)
|
||||||
|
}
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { state.showDatePicker = true },
|
||||||
|
label = { Text(dateLabel, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CalendarToday,
|
||||||
|
contentDescription = strings.nwc.pickDate,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
val timeLabel = remember(state.expiry) {
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()).format(state.expiry)
|
||||||
|
}
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = { state.showTimePicker = true },
|
||||||
|
label = { Text(timeLabel, style = MaterialTheme.typography.bodySmall) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = strings.nwc.pickTime,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Checkbox(
|
||||||
|
checked = state.neverExpires,
|
||||||
|
onCheckedChange = { state.neverExpires = it }
|
||||||
|
)
|
||||||
|
Text(text = strings.nwc.never, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.budgets,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
state.budgets += BudgetDraft(id = state.nextBudgetId)
|
||||||
|
state.nextBudgetId++
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = strings.nwc.addBudget,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(strings.nwc.addBudget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.budgets.forEach { draft ->
|
||||||
|
key(draft.id) {
|
||||||
|
BudgetRow(
|
||||||
|
draft = draft,
|
||||||
|
onChange = { updated ->
|
||||||
|
state.budgets = state.budgets.map { if (it.id == draft.id) updated else it }
|
||||||
|
},
|
||||||
|
onRemove = {
|
||||||
|
state.budgets = state.budgets.filter { it.id != draft.id }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permissions ──────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.permissions,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
ALL_PERMISSIONS.forEach { perm ->
|
||||||
|
key(perm.serverKey) {
|
||||||
|
val checked = perm.serverKey in state.selectedPermissions
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
state.selectedPermissions = if (on)
|
||||||
|
state.selectedPermissions + perm.serverKey
|
||||||
|
else
|
||||||
|
state.selectedPermissions - perm.serverKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(text = perm.displayLabel(strings), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val unnamed = strings.nwc.unnamed
|
||||||
|
// ── Create button ─────────────────────────────────────────────────
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val expiresAtSecs = if (state.neverExpires) 0L
|
||||||
|
else state.expiryEpochSeconds() // #10
|
||||||
|
val validBudgets = state.budgets.mapNotNull { it.toNwcNewBudget() }
|
||||||
|
onCreate(
|
||||||
|
state.description.trim().ifBlank { unnamed },
|
||||||
|
state.selectedPermissions.toList(),
|
||||||
|
expiresAtSecs,
|
||||||
|
validBudgets
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.selectedPermissions.isNotEmpty() && !isCreating,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isCreating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(if (isCreating) strings.nwc.creating else strings.nwc.createConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget row ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BudgetRefreshWindow.displayLabel(strings: AppStrings): String = when (this) {
|
||||||
|
BudgetRefreshWindow.DAILY -> strings.nwc.refreshDaily
|
||||||
|
BudgetRefreshWindow.WEEKLY -> strings.nwc.refreshWeekly
|
||||||
|
BudgetRefreshWindow.MONTHLY -> strings.nwc.refreshMonthly
|
||||||
|
BudgetRefreshWindow.YEARLY -> strings.nwc.refreshYearly
|
||||||
|
BudgetRefreshWindow.NEVER -> strings.nwc.refreshNever
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BudgetRow(
|
||||||
|
draft : BudgetDraft,
|
||||||
|
onChange : (BudgetDraft) -> Unit,
|
||||||
|
onRemove : () -> Unit
|
||||||
|
) {
|
||||||
|
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
val fieldHeight = 64.dp
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.amountSats,
|
||||||
|
onValueChange = { onChange(draft.copy(amountSats = it.filter(Char::isDigit))) },
|
||||||
|
label = { Text(strings.nwc.sats) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f).height(fieldHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = dropdownExpanded,
|
||||||
|
onExpandedChange = { dropdownExpanded = it },
|
||||||
|
modifier = Modifier.weight(1f).height(fieldHeight)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft.refreshWindow.displayLabel(strings),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(strings.nwc.resets) },
|
||||||
|
trailingIcon = {
|
||||||
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(fieldHeight)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = dropdownExpanded,
|
||||||
|
onDismissRequest = { dropdownExpanded = false }
|
||||||
|
) {
|
||||||
|
BudgetRefreshWindow.entries.forEach { window ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(window.displayLabel(strings)) },
|
||||||
|
onClick = {
|
||||||
|
onChange(draft.copy(refreshWindow = window))
|
||||||
|
dropdownExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onRemove,
|
||||||
|
modifier = Modifier.size(fieldHeight)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = strings.nwc.removeBudget,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Cable
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpochShort
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isToday
|
||||||
|
import com.bitcointxoko.gudariwallet.util.isYesterday
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
@Composable
|
||||||
|
internal fun NwcConnectionRow(
|
||||||
|
connection: NwcGetResponse,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
onDeleteConfirm: () -> Unit,
|
||||||
|
onConnectionClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val key = connection.data
|
||||||
|
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
// ── Swipe-to-delete state ────────────────────────────────────────────────
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
|
confirmValueChange = {
|
||||||
|
// Always reject — we never want the row to stay dismissed.
|
||||||
|
// The dialogue is triggered via LaunchedEffect on targetValue below.
|
||||||
|
false
|
||||||
|
},
|
||||||
|
positionalThreshold = { totalDistance -> totalDistance * 0.4f }
|
||||||
|
)
|
||||||
|
|
||||||
|
// targetValue changes to EndToStart as soon as the drag crosses the
|
||||||
|
// threshold, even though confirmValueChange returns false and currentValue
|
||||||
|
// stays at Settled. Use a flag so we only fire once per swipe gesture.
|
||||||
|
var dialogTriggered by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Fires when the swipe crosses the threshold — trigger confirm dialogue
|
||||||
|
LaunchedEffect(dismissState.targetValue) {
|
||||||
|
if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart
|
||||||
|
&& !isDeleting
|
||||||
|
&& !dialogTriggered
|
||||||
|
) {
|
||||||
|
dialogTriggered = true
|
||||||
|
onDeleteConfirm()
|
||||||
|
// Reset in its own scope — won't be cancelled when targetValue changes back
|
||||||
|
scope.launch { dismissState.reset() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires when the row has fully snapped back — re-arm for next swipe
|
||||||
|
LaunchedEffect(dismissState.currentValue) {
|
||||||
|
if (dismissState.currentValue == SwipeToDismissBoxValue.Settled) {
|
||||||
|
dialogTriggered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
modifier = modifier,
|
||||||
|
enableDismissFromStartToEnd = false,
|
||||||
|
enableDismissFromEndToStart = !isDeleting,
|
||||||
|
backgroundContent = {
|
||||||
|
val fraction = dismissState.progress
|
||||||
|
val bgAlpha = (fraction / 0.4f).coerceIn(0f, 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.error.copy(alpha = bgAlpha))
|
||||||
|
.padding(end = 24.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = strings.nwc.deleteConnection,
|
||||||
|
tint = MaterialTheme.colorScheme.onError.copy(alpha = bgAlpha)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clickable(onClick = onConnectionClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Cable,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (expired)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = key.description.ifBlank { strings.nwc.unnamedConnection },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (expired)
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
val lastUsedText = when {
|
||||||
|
key.last_used <= 0L -> strings.nwc.neverUsed
|
||||||
|
isToday(key.last_used) -> strings.nwc.lastUsedToday
|
||||||
|
isYesterday(key.last_used) -> strings.nwc.lastUsedYesterday
|
||||||
|
else -> strings.nwc.lastUsedDate.format(formatEpochShort(key.last_used, strings.today, strings.yesterday))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (key.last_used <= 0L) strings.nwc.neverUsed
|
||||||
|
else lastUsedText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (expired) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
color = semantic.errorContainer,
|
||||||
|
contentColor = semantic.onErrorContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.expired,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Draw
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Link
|
||||||
|
import androidx.compose.material.icons.filled.LinkOff
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.DetailSection
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatEpoch
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── ConnectionDetailScreen.kt ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Displays a Nostr Wallet Connect (NWC) connection and its associated budgets.
|
||||||
|
// Modelled on PaymentDetailScreen; reuses DetailSection / DetailRow / StatusChip
|
||||||
|
// from the same UI kit.
|
||||||
|
//
|
||||||
|
// bitcoin.design guidelines applied:
|
||||||
|
// • Hero card surfaces the most important identity info (wallet + description)
|
||||||
|
// with a clear active/expired status chip — users know at a glance whether
|
||||||
|
// the connection is live.
|
||||||
|
// • Permissions are shown as individual readable rows, not a raw comma string.
|
||||||
|
// • Budget progress uses a LinearProgressIndicator so spending headroom is
|
||||||
|
// immediately scannable without arithmetic.
|
||||||
|
// • Timestamps are human-readable; raw pubkeys are truncated + copyable.
|
||||||
|
// • Destructive "Revoke" action is placed at the bottom, separated from
|
||||||
|
// informational content, and uses errorContainer colours.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConnectionDetailScreen(
|
||||||
|
connection : ConnectionData, // data { pubkey, wallet, description, … }
|
||||||
|
budgets : List<BudgetData>, // budgets [ { id, pubkey, budget_msats, … } ]
|
||||||
|
onBack : () -> Unit,
|
||||||
|
onRevoke : (() -> Unit)? = null // optional – show Revoke button when provided
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(strings.nwc.connectionDetailTitle) }, // ← "Connection"
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = strings.nwc.back // ← "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
ConnectionDetailContent(
|
||||||
|
connection = connection,
|
||||||
|
budgets = budgets,
|
||||||
|
onRevoke = onRevoke,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail content ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionDetailContent(
|
||||||
|
connection : ConnectionData,
|
||||||
|
budgets : List<BudgetData>,
|
||||||
|
onRevoke : (() -> Unit)?,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var showRevokeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val nowSeconds = remember { System.currentTimeMillis() / 1_000L }
|
||||||
|
val isExpired = connection.expiresAt > 0 && connection.expiresAt < nowSeconds
|
||||||
|
val permissions = connection.permissions
|
||||||
|
|
||||||
|
if (showRevokeDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showRevokeDialog = false },
|
||||||
|
title = { Text(strings.nwc.revokeDialogTitle) },
|
||||||
|
text = { Text(strings.nwc.revokeDialogBody) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showRevokeDialog = false
|
||||||
|
onRevoke?.invoke()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.revokeDialogConfirm,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showRevokeDialog = false }) {
|
||||||
|
Text(strings.nwc.revokeDialogDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Hero card ─────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// App / wallet icon placeholder
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Link,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(14.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Wallet name
|
||||||
|
Text(
|
||||||
|
text = connection.description.ifBlank { strings.nwc.connectionUnknownWallet }, // ← "Unknown app"
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Active / Expired chip
|
||||||
|
ConnectionStatusChip(isExpired = isExpired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connection details ────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.nwc.connectionSectionDetails) { // ← "Details"
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowCreated, // ← "Connected"
|
||||||
|
value = formatEpoch(connection.createdAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowLastUsed, // ← "Last used"
|
||||||
|
value = if (connection.lastUsed > 0)
|
||||||
|
formatEpoch(connection.lastUsed, strings.today, strings.yesterday)
|
||||||
|
else
|
||||||
|
strings.nwc.connectionNeverUsed // ← "Never"
|
||||||
|
)
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowExpires, // ← "Expires"
|
||||||
|
value = when {
|
||||||
|
connection.expiresAt <= 0 -> strings.nwc.connectionNoExpiry // ← "Never"
|
||||||
|
isExpired -> strings.nwc.connectionExpired( // ← "Expired · 12 Jan 2025"
|
||||||
|
formatEpoch(connection.expiresAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
else -> formatEpoch(
|
||||||
|
connection.expiresAt,
|
||||||
|
strings.today,
|
||||||
|
strings.yesterday
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// valueColor = if (isExpired) MaterialTheme.colorScheme.error else null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pubkey – truncated display, full value copyable
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowPubkey, // ← "Public key"
|
||||||
|
value = "${connection.pubkey.take(8)}…${connection.pubkey.takeLast(8)}",
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("pubkey", connection.pubkey))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permissions ───────────────────────────────────────────────────────
|
||||||
|
if (permissions.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
DetailSection(title = strings.nwc.connectionSectionPermissions) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
permissions.forEachIndexed { index, permission ->
|
||||||
|
PermissionRow(permission = permission, strings)
|
||||||
|
if (index < permissions.lastIndex) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budgets ───────────────────────────────────────────────────────────
|
||||||
|
if (budgets.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionSectionBudgets, // ← "Spending budgets"
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(budgets, key = { it.id }) { budget ->
|
||||||
|
BudgetCard(budget = budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revoke ────────────────────────────────────────────────────────────
|
||||||
|
if (onRevoke != null) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showRevokeDialog = true },
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LinkOff,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(strings.nwc.connectionRevoke) // ← "Revoke connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget card ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BudgetCard(budget: BudgetData) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
val totalSats = budget.budgetMsats / 1_000L
|
||||||
|
val usedSats = budget.usedBudgetMsats / 1_000L
|
||||||
|
val progress = if (budget.budgetMsats > 0)
|
||||||
|
(1f - budget.usedBudgetMsats.toFloat() / budget.budgetMsats.toFloat()).coerceIn(0f, 1f)
|
||||||
|
else 0f
|
||||||
|
val isOverBudget = budget.usedBudgetMsats > budget.budgetMsats
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Refresh window label e.g. "Daily budget"
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetTitle( // ← "Daily budget"
|
||||||
|
formatRefreshWindow(budget.refreshWindow)
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp)),
|
||||||
|
color = if (isOverBudget)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
// Used / Total
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetUsed(usedSats), // ← "Used: 1,200 sats"
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isOverBudget)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.connectionBudgetTotal(totalSats), // ← "of 10,000 sats"
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created at
|
||||||
|
DetailRow(
|
||||||
|
label = strings.nwc.connectionRowCreated, // ← "Created"
|
||||||
|
value = formatEpoch(budget.createdAt, strings.today, strings.yesterday)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionRow(permission: ConnectionPermission, strings: AppStrings) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = permission.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(7.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = permission.label(strings), // already localized
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status chip ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectionStatusChip(isExpired: Boolean) {
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
color = if (isExpired)
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isExpired) strings.nwc.connectionStatusExpired // ← "Expired"
|
||||||
|
else strings.nwc.connectionStatusActive, // ← "Active"
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isExpired)
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a refresh_window value (seconds) to a human-readable label.
|
||||||
|
* Common NWC values: 86400 = daily, 604800 = weekly, 2592000 = monthly.
|
||||||
|
*/
|
||||||
|
private fun formatRefreshWindow(seconds: Long): String = when (seconds) {
|
||||||
|
0L -> "One-time"
|
||||||
|
86_400L -> "Daily"
|
||||||
|
604_800L -> "Weekly"
|
||||||
|
2_592_000L -> "Monthly"
|
||||||
|
else -> "${seconds / 86_400} days"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a raw NWC method string to a friendly label.
|
||||||
|
* Extend as your permission set grows.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatPermissionLabel(permission: String, strings: AppStrings): String =
|
||||||
|
when (permission.trim().lowercase()) {
|
||||||
|
"pay_invoice" -> strings.nwc.permPayInvoice // ← "Pay invoices"
|
||||||
|
"get_balance" -> strings.nwc.permGetBalance // ← "Read balance"
|
||||||
|
"get_info" -> strings.nwc.permGetInfo // ← "Read wallet info"
|
||||||
|
"make_invoice" -> strings.nwc.permMakeInvoice // ← "Create invoices"
|
||||||
|
"lookup_invoice" -> strings.nwc.permLookupInvoice // ← "Look up invoices"
|
||||||
|
"list_transactions" -> strings.nwc.permListTransactions // ← "View transaction history"
|
||||||
|
"sign_message" -> strings.nwc.permSignMessage // ← "Sign messages"
|
||||||
|
else -> permission // fallback: raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a permission string to a representative icon. */
|
||||||
|
private fun permissionIcon(permission: String): ImageVector =
|
||||||
|
when (permission.trim().lowercase()) {
|
||||||
|
"pay_invoice" -> Icons.Default.ArrowUpward
|
||||||
|
"get_balance" -> Icons.Default.AccountBalanceWallet
|
||||||
|
"get_info" -> Icons.Default.Info
|
||||||
|
"make_invoice" -> Icons.Default.ArrowDownward
|
||||||
|
"lookup_invoice" -> Icons.Default.Search
|
||||||
|
"list_transactions" -> Icons.AutoMirrored.Filled.List
|
||||||
|
"sign_message" -> Icons.Default.Draw
|
||||||
|
else -> Icons.Default.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data models (if not already defined elsewhere) ───────────────────────────
|
||||||
|
|
||||||
|
data class ConnectionData(
|
||||||
|
val pubkey : String,
|
||||||
|
val wallet : String,
|
||||||
|
val description : String,
|
||||||
|
val expiresAt : Long, // Unix seconds; 0 = no expiry
|
||||||
|
val permissions : List<ConnectionPermission>,
|
||||||
|
val createdAt : Long, // Unix seconds
|
||||||
|
val lastUsed : Long // Unix seconds; 0 = never
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved permission — produced by matching NwcKey.permissions against
|
||||||
|
* ALL_PERMISSIONS and carrying only what the detail screen needs.
|
||||||
|
*/
|
||||||
|
data class ConnectionPermission(
|
||||||
|
val serverKey : String, // "pay", "invoice", "lookup", etc.
|
||||||
|
val icon : ImageVector
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun ConnectionPermission.label(strings: AppStrings): String =
|
||||||
|
when (serverKey) {
|
||||||
|
"pay" -> strings.nwc.permSendPayments // "Send payments"
|
||||||
|
"invoice" -> strings.nwc.permCreateInvoices // "Create invoices"
|
||||||
|
"lookup" -> strings.nwc.permLookupInvoiceStatus // "Lookup status of invoice"
|
||||||
|
"history" -> strings.nwc.permReadTransactionHistory // "Read transaction history"
|
||||||
|
"balance" -> strings.nwc.permReadWalletBalance // "Read wallet balance"
|
||||||
|
"info" -> strings.nwc.permReadAccountInfo // "Read account info"
|
||||||
|
else -> serverKey
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetData(
|
||||||
|
val id : Long,
|
||||||
|
val pubkey : String,
|
||||||
|
val budgetMsats : Long,
|
||||||
|
val refreshWindow : Long, // seconds
|
||||||
|
val createdAt : Long,
|
||||||
|
val usedBudgetMsats : Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcKey
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the API model [NwcKey] to the UI domain model [ConnectionData].
|
||||||
|
* Keeps raw API types out of the Composable layer.
|
||||||
|
*/
|
||||||
|
internal fun NwcKey.toConnectionData() = ConnectionData(
|
||||||
|
pubkey = pubkey,
|
||||||
|
wallet = wallet,
|
||||||
|
description = description,
|
||||||
|
expiresAt = expires_at,
|
||||||
|
permissions = parsePermissions(permissions),
|
||||||
|
createdAt = created_at,
|
||||||
|
lastUsed = last_used
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits the raw space-separated permissions string and matches each word
|
||||||
|
* against ALL_PERMISSIONS by serverKey, preserving ALL_PERMISSIONS order.
|
||||||
|
*
|
||||||
|
* "pay invoice lookup history balance info"
|
||||||
|
* → words = {"pay", "invoice", "lookup", "history", "balance", "info"}
|
||||||
|
* → filter ALL_PERMISSIONS where serverKey ∈ words
|
||||||
|
* → map to ConnectionPermission with localised label + icon
|
||||||
|
*/
|
||||||
|
private fun parsePermissions(raw: String): List<ConnectionPermission> {
|
||||||
|
val words = raw.trim().split("\\s+".toRegex()).toSet()
|
||||||
|
return ALL_PERMISSIONS
|
||||||
|
.filter { it.serverKey in words }
|
||||||
|
.map { perm ->
|
||||||
|
ConnectionPermission(
|
||||||
|
serverKey = perm.serverKey,
|
||||||
|
icon = permissionIcon(perm.serverKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun permissionIcon(serverKey: String): ImageVector =
|
||||||
|
when (serverKey) {
|
||||||
|
"pay" -> Icons.Default.ArrowUpward
|
||||||
|
"invoice" -> Icons.Default.ArrowDownward
|
||||||
|
"lookup" -> Icons.Default.Search
|
||||||
|
"history" -> Icons.Default.List
|
||||||
|
"balance" -> Icons.Default.AccountBalanceWallet
|
||||||
|
"info" -> Icons.Default.Info
|
||||||
|
else -> Icons.Default.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the API model [NwcBudget] to the UI domain model [BudgetData].
|
||||||
|
* refresh_window is widened from Int to Long to match [BudgetData].
|
||||||
|
*/
|
||||||
|
internal fun NwcBudget.toBudgetData() = BudgetData(
|
||||||
|
id = id.toLong(),
|
||||||
|
pubkey = pubkey,
|
||||||
|
budgetMsats = budget_msats,
|
||||||
|
refreshWindow = refresh_window.toLong(),
|
||||||
|
createdAt = created_at,
|
||||||
|
usedBudgetMsats = used_budget_msats
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||||
|
|
||||||
|
// ── Available permissions ────────────────────────────────────────────
|
||||||
|
internal data class NwcPermission(
|
||||||
|
val serverKey : String, // what we send in the permissions array
|
||||||
|
val default : Boolean // pre-checked by default
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val ALL_PERMISSIONS = listOf(
|
||||||
|
NwcPermission("pay", default = true),
|
||||||
|
NwcPermission("invoice", default = false),
|
||||||
|
NwcPermission("lookup", default = false),
|
||||||
|
NwcPermission("history", default = false),
|
||||||
|
NwcPermission("balance", default = false),
|
||||||
|
NwcPermission("info", default = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Budget ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient UI state for a single budget row in the "Add connection" sheet.
|
||||||
|
* Converted to [NwcNewBudget] on submit.
|
||||||
|
*/
|
||||||
|
enum class BudgetRefreshWindow(val seconds: Int) {
|
||||||
|
DAILY (86_400),
|
||||||
|
WEEKLY (604_800),
|
||||||
|
MONTHLY (2_592_000),
|
||||||
|
YEARLY (31_536_000),
|
||||||
|
NEVER (0);
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetDraft(
|
||||||
|
val id : Int = 0, // stable key for LazyColumn / forEach
|
||||||
|
val amountSats : String = "", // raw text field value (sats)
|
||||||
|
val refreshWindow : BudgetRefreshWindow = BudgetRefreshWindow.MONTHLY
|
||||||
|
) {
|
||||||
|
/** Returns null when the amount field is blank or non-numeric. */
|
||||||
|
fun toNwcNewBudget(): NwcNewBudget? {
|
||||||
|
val sats = amountSats.trim().toLongOrNull()?.takeIf { it > 0 } ?: return null
|
||||||
|
return NwcNewBudget(
|
||||||
|
budget_msats = sats * 1_000L,
|
||||||
|
refresh_window = refreshWindow.seconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcPairingUrlDialog(
|
||||||
|
url : String,
|
||||||
|
onDismiss : () -> Unit,
|
||||||
|
onNoAppFound : () -> Unit
|
||||||
|
) {
|
||||||
|
// Brightness state lives here so the dialog owns the screen-brightness side-effect
|
||||||
|
val brightness = rememberBrightnessController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(strings.nwc.connectionCreated) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = strings.nwc.scanOrCopyHint,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
val strings = strings
|
||||||
|
QrDisplayCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
content = url,
|
||||||
|
contentDescription = strings.nwc.nwcPairingQrCode,
|
||||||
|
clipLabel = strings.nwc.nwcConnectionString,
|
||||||
|
shareTitle = strings.nwc.shareNwcConnectionString,
|
||||||
|
textToCopy = url,
|
||||||
|
brightnessOn = brightness.isOn,
|
||||||
|
onToggleBrightness = brightness.toggle,
|
||||||
|
isNfcEmulating = null,
|
||||||
|
qrModifier = Modifier.fillMaxWidth(),
|
||||||
|
onOpenUri = {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
|
try {
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(intent, strings.nwc.openInApp)
|
||||||
|
)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
onNoAppFound() // #17: no Toast — caller handles it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {}, // actions live inside QrDisplayCard
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(strings.nwc.done) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NwcScreen(
|
||||||
|
viewModel : NwcViewModel,
|
||||||
|
onBack : () -> Unit,
|
||||||
|
onConnectionClick : (pubkey: String) -> Unit
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
|
||||||
|
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
|
||||||
|
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
|
||||||
|
val showAddSheet by viewModel.showAddSheet.collectAsStateWithLifecycle()
|
||||||
|
val pendingDeletePubkey by viewModel.pendingDeletePubkey.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
|
val refresh = { viewModel.loadConnections(includeExpired = true, strings) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadConnections(includeExpired = true, strings)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(pairingUrl) {
|
||||||
|
if (pairingUrl != null) {
|
||||||
|
viewModel.closeAddSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(strings.nwc.walletConnect) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = strings.nwc.back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(onClick = { viewModel.openAddSheet() }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = strings.nwc.addConnection)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { innerPadding ->
|
||||||
|
|
||||||
|
val isRefreshing = uiState is NwcUiState.Loading ||
|
||||||
|
(uiState as? NwcUiState.Success)?.isRefreshingLastUsed == true
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = refresh,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
when (val state = uiState) {
|
||||||
|
is NwcUiState.Loading -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NwcUiState.Error -> {
|
||||||
|
NwcErrorContent(
|
||||||
|
message = state.message,
|
||||||
|
onRetry = refresh,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NwcUiState.Success -> {
|
||||||
|
if (state.connections.isEmpty()) {
|
||||||
|
NwcEmptyContent(
|
||||||
|
onAddNew = { viewModel.openAddSheet() },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = state.connections,
|
||||||
|
key = { it.data.pubkey }
|
||||||
|
) { connection ->
|
||||||
|
NwcConnectionRow(
|
||||||
|
connection,
|
||||||
|
isDeleting,
|
||||||
|
onDeleteConfirm = { viewModel.requestDelete(connection.data.pubkey) },
|
||||||
|
{ onConnectionClick(connection.data.pubkey) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add connection sheet ───────────────────────────────────────────────────
|
||||||
|
if (showAddSheet) {
|
||||||
|
NwcAddConnectionSheet(
|
||||||
|
isCreating = isCreating,
|
||||||
|
onCreate = { description, permissions, expiresAt, budgets ->
|
||||||
|
viewModel.createConnection(
|
||||||
|
description = description,
|
||||||
|
permissions = permissions,
|
||||||
|
expiresAt = expiresAt,
|
||||||
|
budgets = budgets,
|
||||||
|
strings = strings
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { viewModel.closeAddSheet() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pairing URL dialog ─────────────────────────────────────────────────────
|
||||||
|
pairingUrl?.let { url ->
|
||||||
|
val strings = strings
|
||||||
|
NwcPairingUrlDialog(
|
||||||
|
url = url,
|
||||||
|
onDismiss = { viewModel.clearPairingUrl() },
|
||||||
|
onNoAppFound = {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(strings.nwc.noCompatibleAppFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog ─────────────────────────────────────────────
|
||||||
|
pendingDeletePubkey?.let {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.cancelDelete() },
|
||||||
|
title = { Text(strings.nwc.removeConnection) },
|
||||||
|
text = { Text(strings.nwc.removeConnectionDetail) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.confirmDelete(strings) }
|
||||||
|
) { Text(strings.nwc.remove, color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.cancelDelete() }) { Text(strings.nwc.cancel) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.strings
|
||||||
|
|
||||||
|
// ── Empty state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcEmptyContent(
|
||||||
|
onAddNew : () -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(strings.nwc.noConnectionsYet, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
strings.nwc.tapToAddHint,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Button(onClick = onAddNew) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(strings.nwc.addConnection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun NwcErrorContent(
|
||||||
|
message : String,
|
||||||
|
onRetry : () -> Unit,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
strings.nwc.somethingWentWrong,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = onRetry) { Text(strings.nwc.retry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||||
|
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
|
||||||
|
import com.bitcointxoko.gudariwallet.data.NwcCacheRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||||
|
import com.bitcointxoko.gudariwallet.data.toDomain
|
||||||
|
import com.bitcointxoko.gudariwallet.data.toGetResponse
|
||||||
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
|
||||||
|
import com.bitcointxoko.gudariwallet.util.NwcKeypair
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
private const val TAG = "NwcViewModel"
|
||||||
|
|
||||||
|
// ── UI State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed interface NwcUiState {
|
||||||
|
data object Loading : NwcUiState
|
||||||
|
data class Success(
|
||||||
|
val connections: List<NwcGetResponse>,
|
||||||
|
val isRefreshingLastUsed: Boolean = false // true only while network sync is in-flight
|
||||||
|
) : NwcUiState
|
||||||
|
data class Error(val message: String) : NwcUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ConnectionDetailState {
|
||||||
|
data object Loading : ConnectionDetailState
|
||||||
|
data class Success(
|
||||||
|
val connection : ConnectionData,
|
||||||
|
val budgets : List<BudgetData>
|
||||||
|
) : ConnectionDetailState
|
||||||
|
data class Error(val message: String) : ConnectionDetailState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NwcViewModel(
|
||||||
|
private val repo: WalletRepository,
|
||||||
|
private val cache: NwcCacheRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
|
||||||
|
val uiState: StateFlow<NwcUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
/** Whether a delete operation is currently in flight (disables buttons). */
|
||||||
|
private val _isDeleting = MutableStateFlow(false)
|
||||||
|
val isDeleting: StateFlow<Boolean> = _isDeleting.asStateFlow()
|
||||||
|
|
||||||
|
// Holds the keypair only while the "add connection" flow is active.
|
||||||
|
// Cleared as soon as the user dismisses the QR screen.
|
||||||
|
private val _pendingKeypair = MutableStateFlow<NwcKeypair?>(null)
|
||||||
|
val pendingKeypair: StateFlow<NwcKeypair?> = _pendingKeypair.asStateFlow()
|
||||||
|
|
||||||
|
/** True while a createConnection call is in flight. */
|
||||||
|
private val _isCreating = MutableStateFlow(false)
|
||||||
|
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
|
||||||
|
|
||||||
|
/** Holds the pairing URL after a successful registration, until dismissed. */
|
||||||
|
private val _pairingUrl = MutableStateFlow<String?>(null)
|
||||||
|
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionDetailState =
|
||||||
|
MutableStateFlow<ConnectionDetailState>(ConnectionDetailState.Loading)
|
||||||
|
val connectionDetailState: StateFlow<ConnectionDetailState> =
|
||||||
|
_connectionDetailState.asStateFlow()
|
||||||
|
private var detailObserverJob: Job? = null
|
||||||
|
private var cacheObserverJob: Job? = null
|
||||||
|
|
||||||
|
// ── Sheet / dialog UI state (survives config change) ────────────────────────
|
||||||
|
private var isSyncing = false
|
||||||
|
|
||||||
|
private val _showAddSheet = MutableStateFlow(false)
|
||||||
|
val showAddSheet: StateFlow<Boolean> = _showAddSheet.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingDeletePubkey = MutableStateFlow<String?>(null)
|
||||||
|
val pendingDeletePubkey: StateFlow<String?> = _pendingDeletePubkey.asStateFlow()
|
||||||
|
|
||||||
|
fun openAddSheet() { _showAddSheet.value = true }
|
||||||
|
fun closeAddSheet() { _showAddSheet.value = false }
|
||||||
|
|
||||||
|
fun requestDelete(pubkey: String) { _pendingDeletePubkey.value = pubkey }
|
||||||
|
fun cancelDelete() { _pendingDeletePubkey.value = null }
|
||||||
|
// confirmDelete() calls the existing deleteConnection() then clears the pending key
|
||||||
|
fun confirmDelete(strings: AppStrings) {
|
||||||
|
_pendingDeletePubkey.value?.let { deleteConnection(it, strings) }
|
||||||
|
_pendingDeletePubkey.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun loadConnections(includeExpired: Boolean = false, strings: AppStrings) {
|
||||||
|
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
|
||||||
|
isSyncing = true
|
||||||
|
|
||||||
|
// 1. Subscribe to cache — emits immediately if rows exist, stays alive
|
||||||
|
// for the ViewModel lifetime. Every cache write re-emits automatically.
|
||||||
|
if (cacheObserverJob == null) {
|
||||||
|
// Fast path: one-shot read populates UI before the Flow cold-starts
|
||||||
|
viewModelScope.launch {
|
||||||
|
val snapshot = cache.getKeysOnce()
|
||||||
|
if (snapshot.isNotEmpty() && _uiState.value !is NwcUiState.Success) {
|
||||||
|
Timber.d("[$TAG] cache snapshot: ${snapshot.size} connection(s)")
|
||||||
|
_uiState.value = NwcUiState.Success(
|
||||||
|
connections = snapshot,
|
||||||
|
isRefreshingLastUsed = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacheObserverJob = cache.observeKeys()
|
||||||
|
.drop(1) // skip first emission — snapshot already handled it
|
||||||
|
.onEach { cached ->
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
Timber.d("[$TAG] cache hit: ${cached.size} connection(s)")
|
||||||
|
_uiState.value = NwcUiState.Success(
|
||||||
|
connections = cached.map { it.toDomain().toGetResponse() },
|
||||||
|
isRefreshingLastUsed = isSyncing // ← reflects current sync state, not always true
|
||||||
|
)
|
||||||
|
} else if (_uiState.value !is NwcUiState.Success) {
|
||||||
|
_uiState.value = NwcUiState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch from network; result is written to cache which re-emits above
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.getNwcKeys(includeExpired) }
|
||||||
|
.onSuccess { keys ->
|
||||||
|
Timber.i("[$TAG] network: ${keys.size} NWC connection(s)")
|
||||||
|
isSyncing = false
|
||||||
|
cache.syncKeys(keys) // Room write → Flow re-emits
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
Timber.e(e, "[$TAG] failed to load NWC connections")
|
||||||
|
// Only show error if we have nothing cached to show
|
||||||
|
isSyncing = false
|
||||||
|
if (_uiState.value !is NwcUiState.Success) {
|
||||||
|
_uiState.value = NwcUiState.Error(
|
||||||
|
e.message ?: strings.nwc.failedToLoadConnections
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w("[$TAG] network failed but cache is showing — non-fatal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a keypair, registers it with the server, then fetches
|
||||||
|
* the nostr+walletconnect:// pairing URL.
|
||||||
|
* Private key is held only in local scope — never stored.
|
||||||
|
*/
|
||||||
|
fun createConnection(
|
||||||
|
description : String,
|
||||||
|
permissions : List<String>,
|
||||||
|
expiresAt : Long = 0L,
|
||||||
|
budgets : List<NwcNewBudget> = emptyList(),
|
||||||
|
strings : AppStrings
|
||||||
|
) {
|
||||||
|
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isCreating.value = true
|
||||||
|
try {
|
||||||
|
runCatching {
|
||||||
|
val keypair = NwcKeyGenerator.generate()
|
||||||
|
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}…")
|
||||||
|
check(keypair.pubKeyHex.length == 64) {
|
||||||
|
"pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}"
|
||||||
|
}
|
||||||
|
check(keypair.privKeyHex.length == 64) {
|
||||||
|
"privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}"
|
||||||
|
}
|
||||||
|
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
|
||||||
|
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
|
||||||
|
|
||||||
|
val registered = repo.registerNwcKey(
|
||||||
|
pubkey = keypair.pubKeyHex,
|
||||||
|
request = NwcRegistrationRequest(
|
||||||
|
description = description,
|
||||||
|
permissions = permissions,
|
||||||
|
expires_at = expiresAt,
|
||||||
|
budgets = budgets
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Timber.i("[$TAG] registered key ✓")
|
||||||
|
cache.saveKey(registered)
|
||||||
|
Timber.d("[$TAG] new key cached ✓")
|
||||||
|
val url = repo.getNwcPairingUrl(keypair.privKeyHex)
|
||||||
|
Timber.i("[$TAG] pairing URL fetched ✓")
|
||||||
|
Timber.d("[$TAG] URI=$url")
|
||||||
|
url
|
||||||
|
}
|
||||||
|
.onSuccess { url ->
|
||||||
|
_pairingUrl.value = url
|
||||||
|
silentRefresh()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
Timber.e(e, "[$TAG] createConnection failed")
|
||||||
|
_uiState.value = NwcUiState.Error(e.message ?: strings.nwc.failedToCreateConnection)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wipes the pairing URL from memory once the user has copied it. */
|
||||||
|
fun clearPairingUrl() {
|
||||||
|
Timber.d("[$TAG] clearing pairing URL from memory")
|
||||||
|
_pairingUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete / Revoke ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an NWC key by pubkey. Used by both the list screen (swipe-to-delete)
|
||||||
|
* and the detail screen (revoke button). Sets [_isDeleting] for the duration
|
||||||
|
* so the list screen can disable row interactions while in flight.
|
||||||
|
*/
|
||||||
|
fun deleteConnection(pubkey: String, strings: AppStrings) {
|
||||||
|
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}…")
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isDeleting.value = true
|
||||||
|
try {
|
||||||
|
runCatching { repo.deleteNwcKey(pubkey) }
|
||||||
|
.onSuccess {
|
||||||
|
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
|
||||||
|
cache.deleteKey(pubkey)
|
||||||
|
val current = _uiState.value
|
||||||
|
if (current is NwcUiState.Success) {
|
||||||
|
_uiState.value = NwcUiState.Success(
|
||||||
|
current.connections.filter { it.data.pubkey != pubkey }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
silentRefresh()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}…")
|
||||||
|
_uiState.value = NwcUiState.Error(
|
||||||
|
e.message ?: strings.nwc.failedToDeleteConnection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun loadConnectionDetail(pubkey: String, strings: AppStrings) {
|
||||||
|
Timber.d("[$TAG] loadConnectionDetail pubkey=${pubkey.take(16)}…")
|
||||||
|
|
||||||
|
// Cancel any subscription from a previous key — prevents stale Flow
|
||||||
|
// from a previously viewed key overwriting the current detail state
|
||||||
|
detailObserverJob?.cancel()
|
||||||
|
_connectionDetailState.value = ConnectionDetailState.Loading
|
||||||
|
|
||||||
|
detailObserverJob = combine(
|
||||||
|
cache.observeKey(pubkey),
|
||||||
|
cache.observeBudgets(pubkey)
|
||||||
|
) { keyEntity, budgetEntities ->
|
||||||
|
Pair(keyEntity, budgetEntities)
|
||||||
|
}
|
||||||
|
.onEach { (keyEntity, budgetEntities) ->
|
||||||
|
if (keyEntity != null) {
|
||||||
|
Timber.d("[$TAG] detail cache hit for ${pubkey.take(16)}…")
|
||||||
|
_connectionDetailState.value = ConnectionDetailState.Success(
|
||||||
|
connection = keyEntity.toDomain().toConnectionData(),
|
||||||
|
budgets = budgetEntities.map { it.toDomain().toBudgetData() }
|
||||||
|
)
|
||||||
|
} else if (_connectionDetailState.value !is ConnectionDetailState.Success) {
|
||||||
|
_connectionDetailState.value = ConnectionDetailState.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope) // ← now tracked and cancellable
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repo.getNwcKey(pubkey = pubkey, includeExpired = true, refreshLastUsed = false)
|
||||||
|
}
|
||||||
|
.onSuccess { response ->
|
||||||
|
Timber.i("[$TAG] loaded connection detail ✓")
|
||||||
|
cache.refreshLastUsed(pubkey, response.data.last_used)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
Timber.e(e, "[$TAG] failed to load connection detail")
|
||||||
|
if (_connectionDetailState.value !is ConnectionDetailState.Success) {
|
||||||
|
_connectionDetailState.value = ConnectionDetailState.Error(
|
||||||
|
e.message ?: strings.nwc.failedToLoadConnection
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Timber.w("[$TAG] detail network refresh failed — cache showing (non-fatal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset to Loading so the next open always shows a fresh load. */
|
||||||
|
fun clearConnectionDetail() {
|
||||||
|
Timber.d("[$TAG] clearConnectionDetail")
|
||||||
|
detailObserverJob?.cancel() // ← stop the old key's Flow immediately
|
||||||
|
detailObserverJob = null
|
||||||
|
_connectionDetailState.value = ConnectionDetailState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Silent refresh (no Loading flash) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the connection list from the server without transitioning to
|
||||||
|
* [NwcUiState.Loading] first. Used after mutations (create, delete) where
|
||||||
|
* the UI already shows the correct optimistic state.
|
||||||
|
* Failures are non-fatal — the optimistic state is already correct.
|
||||||
|
*/
|
||||||
|
private fun silentRefresh(includeExpired: Boolean = false) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.getNwcKeys(includeExpired) }
|
||||||
|
.onSuccess { keys ->
|
||||||
|
Timber.d("[$TAG] silentRefresh: ${keys.size} connection(s)")
|
||||||
|
cache.syncKeys(keys) // Room write → cache Flow re-emits to UI
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
Timber.w(e, "[$TAG] silentRefresh failed (non-fatal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Factory ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val repo: WalletRepository,
|
||||||
|
private val cache: NwcCacheRepository
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
NwcViewModel(repo, cache) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Fingerprint
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BiometricLockPage(
|
||||||
|
appLockEnabled: Boolean,
|
||||||
|
onAppLockChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val strings = LocalAppStrings.current.lock
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.appLockTitle,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = strings.appLockDescription,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = strings.appLockToggleLabel,
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (appLockEnabled) strings.appLockToggleEnabled
|
||||||
|
else strings.appLockToggleDisabled,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = appLockEnabled,
|
||||||
|
onCheckedChange = onAppLockChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ fun NotificationsPage(
|
|||||||
imageVector = Icons.Default.Notifications,
|
imageVector = Icons.Default.Notifications,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(72.dp),
|
modifier = Modifier.size(72.dp),
|
||||||
// Mirror the BatteryPage tint behaviour
|
// Mirror the BatteryPage tint behavior
|
||||||
tint = if (alreadyGranted)
|
tint = if (alreadyGranted)
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else
|
else
|
||||||
@@ -300,7 +300,7 @@ fun NotificationsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page 5: Battery optimisation ──────────────────────────────────────────────
|
// ── Page 5: Battery optimization ──────────────────────────────────────────────
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryPage(strings: AppStrings) {
|
fun BatteryPage(strings: AppStrings) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
|
import com.bitcointxoko.gudariwallet.data.AppLockStore
|
||||||
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
import com.bitcointxoko.gudariwallet.data.HistoricalSyncStore
|
||||||
import com.bitcointxoko.gudariwallet.security.SecretStore
|
import com.bitcointxoko.gudariwallet.security.SecretStore
|
||||||
import com.bitcointxoko.gudariwallet.sync.HistoricalSyncWorker
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val PAGE_WELCOME = 0
|
private const val PAGE_WELCOME = 0
|
||||||
private const val PAGE_SERVER_URL = 1
|
private const val PAGE_SERVER_URL = 1
|
||||||
@@ -26,13 +26,15 @@ private const val PAGE_INVOICE = 2
|
|||||||
private const val PAGE_ADMIN = 3
|
private const val PAGE_ADMIN = 3
|
||||||
private const val PAGE_NOTIFICATIONS = 4
|
private const val PAGE_NOTIFICATIONS = 4
|
||||||
private const val PAGE_BATTERY = 5
|
private const val PAGE_BATTERY = 5
|
||||||
private const val PAGE_SYNC = 6 // ← new
|
private const val PAGE_BIOMETRIC = 6
|
||||||
private const val PAGE_COUNT = 7 // ← was 6
|
private const val PAGE_SYNC = 7
|
||||||
|
private const val PAGE_COUNT = 8
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
secretStore : SecretStore,
|
secretStore : SecretStore,
|
||||||
syncStore : HistoricalSyncStore, // ← new
|
syncStore : HistoricalSyncStore,
|
||||||
|
appLockStore : AppLockStore,
|
||||||
onRequestNotificationPermission: () -> Unit,
|
onRequestNotificationPermission: () -> Unit,
|
||||||
onComplete : () -> Unit
|
onComplete : () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -45,12 +47,11 @@ fun OnboardingScreen(
|
|||||||
var adminKey by remember { mutableStateOf("") }
|
var adminKey by remember { mutableStateOf("") }
|
||||||
var adminVisible by remember { mutableStateOf(false) }
|
var adminVisible by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var credentialsSaved by remember { mutableStateOf(false) }
|
var appLockEnabled by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// ── Sync state ────────────────────────────────────────────────────────────
|
// ── Sync state ────────────────────────────────────────────────────────────
|
||||||
val vm: OnboardingViewModel = viewModel(
|
val vm: OnboardingViewModel = viewModel(
|
||||||
factory = OnboardingViewModelFactory(
|
factory = OnboardingViewModelFactory(
|
||||||
// LocalContext.current.applicationContext cast to Application
|
|
||||||
app = androidx.compose.ui.platform.LocalContext.current
|
app = androidx.compose.ui.platform.LocalContext.current
|
||||||
.applicationContext as android.app.Application,
|
.applicationContext as android.app.Application,
|
||||||
secretStore = secretStore,
|
secretStore = secretStore,
|
||||||
@@ -64,31 +65,33 @@ fun OnboardingScreen(
|
|||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) { error = null }
|
LaunchedEffect(pagerState.currentPage) { error = null }
|
||||||
|
|
||||||
var previousPage by remember { mutableIntStateOf(0) }
|
val hexKeyRegex = Regex("^[0-9a-f]{32}$")
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
|
||||||
val current = pagerState.currentPage
|
|
||||||
if (current > previousPage) {
|
|
||||||
// User moved forward — save whatever the previous page owned
|
|
||||||
when (previousPage) {
|
|
||||||
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
|
|
||||||
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
|
|
||||||
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previousPage = current
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateCurrentPage(): Boolean {
|
fun validateCurrentPage(): Boolean {
|
||||||
error = when (pagerState.currentPage) {
|
error = when (pagerState.currentPage) {
|
||||||
PAGE_SERVER_URL -> if (baseUrl.isBlank()) strings.onboarding.fieldsRequired else null
|
PAGE_SERVER_URL -> when {
|
||||||
PAGE_INVOICE -> if (invoiceKey.isBlank()) strings.onboarding.fieldsRequired else null
|
baseUrl.isBlank() -> strings.onboarding.fieldsRequired
|
||||||
PAGE_ADMIN -> if (adminKey.isBlank()) strings.onboarding.fieldsRequired else null
|
else -> null
|
||||||
else -> null
|
}
|
||||||
|
|
||||||
|
PAGE_INVOICE -> when {
|
||||||
|
invoiceKey.isBlank() -> strings.onboarding.fieldsRequired
|
||||||
|
!hexKeyRegex.matches(invoiceKey) -> strings.onboarding.invalidKeyFormat
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
PAGE_ADMIN -> when {
|
||||||
|
adminKey.isBlank() -> strings.onboarding.fieldsRequired
|
||||||
|
!hexKeyRegex.matches(adminKey) -> strings.onboarding.invalidKeyFormat
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
return error == null
|
return error == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Scaffold { innerPadding ->
|
Scaffold { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -119,6 +122,10 @@ fun OnboardingScreen(
|
|||||||
onRequestNotificationPermission = onRequestNotificationPermission
|
onRequestNotificationPermission = onRequestNotificationPermission
|
||||||
)
|
)
|
||||||
PAGE_BATTERY -> BatteryPage(strings)
|
PAGE_BATTERY -> BatteryPage(strings)
|
||||||
|
PAGE_BIOMETRIC -> BiometricLockPage(
|
||||||
|
appLockEnabled = appLockEnabled,
|
||||||
|
onAppLockChanged = { appLockEnabled = it }
|
||||||
|
)
|
||||||
PAGE_SYNC -> SyncHistoryPage(
|
PAGE_SYNC -> SyncHistoryPage(
|
||||||
workInfo = syncWorkInfo,
|
workInfo = syncWorkInfo,
|
||||||
isAlreadyDone = syncAlreadyDone,
|
isAlreadyDone = syncAlreadyDone,
|
||||||
@@ -127,7 +134,7 @@ fun OnboardingScreen(
|
|||||||
onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) },
|
onSchedule = { delayMs -> vm.enqueueHistoricalSync(delayMillis = delayMs) },
|
||||||
onContinue = {
|
onContinue = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
secretStore.setOnboarded() // moved here from PAGE_BATTERY
|
secretStore.setOnboarded()
|
||||||
onComplete()
|
onComplete()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,17 +174,28 @@ fun OnboardingScreen(
|
|||||||
Spacer(Modifier.width(1.dp))
|
Spacer(Modifier.width(1.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLastPage = pagerState.currentPage == PAGE_BATTERY
|
// val isLastPage = pagerState.currentPage == PAGE_BATTERY
|
||||||
|
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
if (!validateCurrentPage()) return@Button
|
if (!validateCurrentPage()) return@Button
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Save each field as soon as the user advances past it
|
|
||||||
when (pagerState.currentPage) {
|
when (pagerState.currentPage) {
|
||||||
PAGE_SERVER_URL -> secretStore.saveBaseUrl(baseUrl)
|
PAGE_SERVER_URL -> {
|
||||||
PAGE_INVOICE -> secretStore.saveInvoiceKey(invoiceKey)
|
Timber.d("ONBOARDING saving baseUrl (len=${baseUrl.length}): $baseUrl")
|
||||||
PAGE_ADMIN -> secretStore.saveAdminKey(adminKey)
|
secretStore.saveBaseUrl(baseUrl)
|
||||||
else -> Unit
|
}
|
||||||
|
PAGE_INVOICE -> {
|
||||||
|
Timber.d("ONBOARDING saving invoiceKey (len=${invoiceKey.length}): $invoiceKey")
|
||||||
|
secretStore.saveInvoiceKey(invoiceKey)
|
||||||
|
}
|
||||||
|
PAGE_ADMIN -> {
|
||||||
|
Timber.d("ONBOARDING saving adminKey (len=${adminKey.length}): $adminKey")
|
||||||
|
secretStore.saveAdminKey(adminKey)
|
||||||
|
}
|
||||||
|
PAGE_BIOMETRIC -> {
|
||||||
|
scope.launch { appLockStore.setAppLockEnabled(appLockEnabled) }
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ internal fun AmountDisplay(
|
|||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = strings.amountDisplaySats(amountSats), // ← "1 sat" or "X sats"
|
text = strings.receive.amountDisplaySats(amountSats), // ← "1 sat" or "X sats"
|
||||||
style = style,
|
style = style,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.receive
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.BrightnessHigh
|
|
||||||
import androidx.compose.material.icons.filled.BrightnessLow
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun BrightnessToggleButton(
|
|
||||||
isOn : Boolean,
|
|
||||||
onToggle: () -> Unit
|
|
||||||
) {
|
|
||||||
val strings = LocalAppStrings.current
|
|
||||||
|
|
||||||
IconButton(onClick = onToggle) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isOn) Icons.Filled.BrightnessHigh
|
|
||||||
else Icons.Filled.BrightnessLow,
|
|
||||||
contentDescription = if (isOn) strings.brightnessReduce // ← "Reduce brightness"
|
|
||||||
else strings.brightnessBoost, // ← "Boost brightness"
|
|
||||||
tint = if (isOn) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.bitcointxoko.gudariwallet.ui.receive
|
package com.bitcointxoko.gudariwallet.ui.receive
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -58,15 +56,15 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
||||||
import com.bitcointxoko.gudariwallet.ui.ReceiveState
|
import com.bitcointxoko.gudariwallet.ui.ReceiveState
|
||||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker
|
import com.bitcointxoko.gudariwallet.ui.common.QrDisplayCard
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.rememberBrightnessController
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.common.UnitWheelPicker
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||||
import com.bitcointxoko.gudariwallet.util.buildUpTaps
|
import com.bitcointxoko.gudariwallet.util.buildUpTaps
|
||||||
import com.bitcointxoko.gudariwallet.util.doubleTap
|
|
||||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||||
import com.bitcointxoko.gudariwallet.util.heavyClick
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReceiveScreen(
|
fun ReceiveScreen(
|
||||||
@@ -74,32 +72,17 @@ fun ReceiveScreen(
|
|||||||
nfcVm : NfcViewModel,
|
nfcVm : NfcViewModel,
|
||||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val brightness = rememberBrightnessController()
|
||||||
|
var showAddress by remember { mutableStateOf(false) }
|
||||||
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
||||||
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
|
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
|
||||||
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
|
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
|
||||||
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
|
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val window = (context as Activity).window
|
|
||||||
val originalBrightness = remember { window.attributes.screenBrightness }
|
|
||||||
var brightnessOn by remember { mutableStateOf(false) }
|
|
||||||
var showAddress by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val onToggleBrightness = {
|
|
||||||
val lp = window.attributes
|
|
||||||
lp.screenBrightness = if (!brightnessOn)
|
|
||||||
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
|
||||||
else originalBrightness
|
|
||||||
window.attributes = lp
|
|
||||||
brightnessOn = !brightnessOn
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
val lp = window.attributes
|
|
||||||
lp.screenBrightness = originalBrightness
|
|
||||||
window.attributes = lp
|
|
||||||
nfcVm.stopEmulating()
|
nfcVm.stopEmulating()
|
||||||
vm.resetReceiveState()
|
vm.resetReceiveState()
|
||||||
}
|
}
|
||||||
@@ -141,14 +124,14 @@ fun ReceiveScreen(
|
|||||||
lightningAddress = lightningAddress,
|
lightningAddress = lightningAddress,
|
||||||
showAddress = showAddress,
|
showAddress = showAddress,
|
||||||
onToggleAddress = { showAddress = !showAddress },
|
onToggleAddress = { showAddress = !showAddress },
|
||||||
brightnessOn = brightnessOn,
|
brightnessOn = brightness.isOn,
|
||||||
onToggleBrightness = onToggleBrightness,
|
onToggleBrightness = brightness.toggle,
|
||||||
isLoading = receiveState is ReceiveState.AwaitingInvoice,
|
isLoading = receiveState is ReceiveState.AwaitingInvoice,
|
||||||
onCreateInvoice = { amountSats, memo ->
|
onCreateInvoice = { amountSats, memo ->
|
||||||
vm.createInvoice(
|
vm.createInvoice(
|
||||||
amountSats,
|
amountSats,
|
||||||
memo,
|
memo,
|
||||||
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice
|
failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -163,9 +146,9 @@ fun ReceiveScreen(
|
|||||||
callback = state.callback,
|
callback = state.callback,
|
||||||
amountSats = amountSats,
|
amountSats = amountSats,
|
||||||
memo = state.defaultDescription,
|
memo = state.defaultDescription,
|
||||||
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice,
|
failedToCreateInvoice = strings.receive.receiveVmFailedToCreateInvoice,
|
||||||
withdrawRejected = strings.receiveVmWithdrawRejected,
|
withdrawRejected = strings.receive.receiveVmWithdrawRejected,
|
||||||
withdrawFailed = strings.receiveVmWithdrawFailed,
|
withdrawFailed = strings.receive.receiveVmWithdrawFailed,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDismiss = { vm.resetReceiveState() }
|
onDismiss = { vm.resetReceiveState() }
|
||||||
@@ -190,7 +173,7 @@ fun ReceiveScreen(
|
|||||||
modifier = Modifier.padding(24.dp)
|
modifier = Modifier.padding(24.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.receiveError, // ← "Error"
|
text = strings.receive.receiveError, // ← "Error"
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
@@ -216,8 +199,8 @@ fun ReceiveScreen(
|
|||||||
state = receiveState as ReceiveState.InvoiceReady,
|
state = receiveState as ReceiveState.InvoiceReady,
|
||||||
fiatRate = fiatRate,
|
fiatRate = fiatRate,
|
||||||
fiatCurrency = fiatCurrency,
|
fiatCurrency = fiatCurrency,
|
||||||
brightnessOn = brightnessOn,
|
brightnessOn = brightness.isOn,
|
||||||
onToggleBrightness = onToggleBrightness,
|
onToggleBrightness = brightness.toggle,
|
||||||
nfcVm = nfcVm,
|
nfcVm = nfcVm,
|
||||||
onReset = { vm.resetReceiveState() }
|
onReset = { vm.resetReceiveState() }
|
||||||
)
|
)
|
||||||
@@ -243,8 +226,8 @@ private fun LnurlWithdrawSheet(
|
|||||||
val parsed = amountText.trim().toLongOrNull()
|
val parsed = amountText.trim().toLongOrNull()
|
||||||
return when {
|
return when {
|
||||||
parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount"
|
parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount"
|
||||||
parsed < state.minSats -> { amountError = strings.minimumSats(state.minSats); null } // ← "Minimum is X sats"
|
parsed < state.minSats -> { amountError = strings.receive.minimumSats(state.minSats); null } // ← "Minimum is X sats"
|
||||||
parsed > state.maxSats -> { amountError = strings.maximumSats(state.maxSats); null } // ← "Maximum is X sats"
|
parsed > state.maxSats -> { amountError = strings.receive.maximumSats(state.maxSats); null } // ← "Maximum is X sats"
|
||||||
else -> { amountError = null; parsed }
|
else -> { amountError = null; parsed }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +248,7 @@ private fun LnurlWithdrawSheet(
|
|||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = strings.withdrawFrom, // ← "Withdraw from"
|
text = strings.receive.withdrawFrom, // ← "Withdraw from"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -304,7 +287,7 @@ private fun LnurlWithdrawSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = strings.withdrawButtonAmount(state.maxSats), // ← "X sats"
|
text = strings.receive.withdrawButtonAmount(state.maxSats), // ← "X sats"
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
@@ -315,12 +298,12 @@ private fun LnurlWithdrawSheet(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = amountText,
|
value = amountText,
|
||||||
onValueChange = { amountText = it; amountError = null },
|
onValueChange = { amountText = it; amountError = null },
|
||||||
label = { Text(strings.amountInSats) }, // ← "Amount (sats)"
|
label = { Text(strings.receive.amountInSats) }, // ← "Amount (sats)"
|
||||||
supportingText = {
|
supportingText = {
|
||||||
if (amountError != null)
|
if (amountError != null)
|
||||||
Text(amountError!!, color = MaterialTheme.colorScheme.error)
|
Text(amountError!!, color = MaterialTheme.colorScheme.error)
|
||||||
else
|
else
|
||||||
Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X – Y sats"
|
Text(strings.receive.satsRange(state.minSats, state.maxSats)) // ← "X – Y sats"
|
||||||
},
|
},
|
||||||
isError = amountError != null,
|
isError = amountError != null,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
@@ -339,8 +322,8 @@ private fun LnurlWithdrawSheet(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (fixedAmount) strings.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats"
|
if (fixedAmount) strings.receive.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats"
|
||||||
else strings.withdrawButton // ← "Withdraw"
|
else strings.receive.withdrawButton // ← "Withdraw"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +380,7 @@ private fun ReceiveIdleContent(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = strings.receive, // ← "Receive"
|
text = strings.receive.receive, // ← "Receive"
|
||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall
|
||||||
)
|
)
|
||||||
if (lightningAddress != null) {
|
if (lightningAddress != null) {
|
||||||
@@ -438,12 +421,12 @@ private fun ReceiveIdleContent(
|
|||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
QrDisplayCard(
|
QrDisplayCard(
|
||||||
content = lightningAddress,
|
content = lightningAddress,
|
||||||
contentDescription = strings.lightningAddressQrCode, // ← "Lightning address QR code"
|
contentDescription = strings.receive.lightningAddressQrCode, // ← "Lightning address QR code"
|
||||||
clipLabel = strings.lightningAddressLabel, // ← "Lightning Address"
|
clipLabel = strings.receive.lightningAddressLabel, // ← "Lightning Address"
|
||||||
shareTitle = strings.shareLightningAddress, // ← "Share Lightning Address"
|
shareTitle = strings.receive.shareLightningAddress, // ← "Share Lightning Address"
|
||||||
textToCopy = lightningAddress,
|
textToCopy = lightningAddress,
|
||||||
brightnessOn = brightnessOn,
|
brightnessOn = brightnessOn,
|
||||||
onToggleBrightness = onToggleBrightness
|
onToggleBrightness = onToggleBrightness
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -512,7 +495,7 @@ private fun AmountStepContent(
|
|||||||
val number = amountText.trim().toDoubleOrNull() ?: return@remember null
|
val number = amountText.trim().toDoubleOrNull() ?: return@remember null
|
||||||
when (activeUnit) {
|
when (activeUnit) {
|
||||||
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
|
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
|
||||||
AmountUnit.FIAT -> "≈ ${(number * fiatRate!!).toLong()} sats"
|
AmountUnit.FIAT -> "≈ ${(number * fiatRate).toLong()} sats"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +543,7 @@ private fun AmountStepContent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
UnitWheelPicker(
|
UnitWheelPicker(
|
||||||
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency!!) else listOf(strings.sats),
|
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency) else listOf(strings.sats),
|
||||||
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
||||||
onIndexSelected = { newIndex ->
|
onIndexSelected = { newIndex ->
|
||||||
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
||||||
@@ -605,8 +588,8 @@ private fun MemoStepContent(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = memo,
|
value = memo,
|
||||||
onValueChange = { memo = it },
|
onValueChange = { memo = it },
|
||||||
label = { Text(strings.memoOptional) }, // ← "Memo (optional)"
|
label = { Text(strings.receive.memoOptional) }, // ← "Memo (optional)"
|
||||||
placeholder = { Text(strings.memoPlaceholder) }, // ← "What's this for?"
|
placeholder = { Text(strings.receive.memoPlaceholder) }, // ← "What's this for?"
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -621,8 +604,8 @@ private fun MemoStepContent(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (isLoading) strings.creatingInvoice // ← "Creating invoice…"
|
if (isLoading) strings.receive.creatingInvoice // ← "Creating invoice…"
|
||||||
else strings.createInvoice // ← "Create Invoice"
|
else strings.receive.createInvoice // ← "Create Invoice"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,7 +637,7 @@ private fun ReceiveInvoiceContent(
|
|||||||
}
|
}
|
||||||
LaunchedEffect(state.expiresAt) {
|
LaunchedEffect(state.expiresAt) {
|
||||||
while (secondsLeft > 0L) {
|
while (secondsLeft > 0L) {
|
||||||
delay(1_000L)
|
delay(1_000L.milliseconds)
|
||||||
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
|
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
|
||||||
.coerceAtLeast(0L)
|
.coerceAtLeast(0L)
|
||||||
}
|
}
|
||||||
@@ -682,23 +665,23 @@ private fun ReceiveInvoiceContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
QrDisplayCard(
|
QrDisplayCard(
|
||||||
content = state.bolt11.uppercase(),
|
content = state.bolt11.uppercase(),
|
||||||
contentDescription = strings.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
|
contentDescription = strings.receive.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
|
||||||
clipLabel = strings.invoiceLabel, // ← "Invoice"
|
clipLabel = strings.receive.invoiceLabel, // ← "Invoice"
|
||||||
shareTitle = strings.shareInvoice, // ← "Share Invoice"
|
shareTitle = strings.receive.shareInvoice, // ← "Share Invoice"
|
||||||
textToCopy = state.bolt11,
|
textToCopy = state.bolt11,
|
||||||
brightnessOn = brightnessOn,
|
brightnessOn = brightnessOn,
|
||||||
onToggleBrightness = onToggleBrightness,
|
onToggleBrightness = onToggleBrightness,
|
||||||
isNfcEmulating = isEmulating,
|
isNfcEmulating = isEmulating,
|
||||||
onToggleNfc = {
|
onToggleNfc = {
|
||||||
if (isEmulating) nfcVm.stopEmulating()
|
if (isEmulating) nfcVm.stopEmulating()
|
||||||
else nfcVm.startEmulating("lightning:${state.bolt11}")
|
else nfcVm.startEmulating("lightning:${state.bolt11}")
|
||||||
},
|
},
|
||||||
nfcEnabled = !isExpired,
|
nfcEnabled = !isExpired,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
qrModifier = Modifier
|
qrModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -711,7 +694,7 @@ private fun ReceiveInvoiceContent(
|
|||||||
if (!isExpired) {
|
if (!isExpired) {
|
||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
Text(
|
Text(
|
||||||
text = strings.waiting, // ← "Waiting…"
|
text = strings.receive.waiting, // ← "Waiting…"
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
@@ -722,7 +705,7 @@ private fun ReceiveInvoiceContent(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = strings.invoiceExpired, // ← "Invoice expired"
|
text = strings.receive.invoiceExpired, // ← "Invoice expired"
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
@@ -734,7 +717,7 @@ private fun ReceiveInvoiceContent(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (isExpired) strings.newInvoice // ← "New Invoice"
|
if (isExpired) strings.receive.newInvoice // ← "New Invoice"
|
||||||
else strings.cancel // ← "Cancel"
|
else strings.cancel // ← "Cancel"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -768,7 +751,7 @@ private fun ReceiveSuccessContent(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = strings.paymentReceived, // ← "Payment Received!"
|
text = strings.receive.paymentReceived, // ← "Payment Received!"
|
||||||
style = MaterialTheme.typography.headlineSmall
|
style = MaterialTheme.typography.headlineSmall
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class ReceiveViewModel(
|
|||||||
// ── Invoice creation ──────────────────────────────────────────────────────
|
// ── Invoice creation ──────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* @param failedToCreateInvoice Localised error string from the call site,
|
* @param failedToCreateInvoice Localised error string from the call site,
|
||||||
* e.g. `strings.receiveVmFailedToCreateInvoice`
|
* e.g. `strings.receive.receiveVmFailedToCreateInvoice`
|
||||||
*/
|
*/
|
||||||
fun createInvoice(
|
fun createInvoice(
|
||||||
amountSats : Long,
|
amountSats : Long,
|
||||||
@@ -104,9 +104,9 @@ class ReceiveViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param failedToCreateInvoice `strings.receiveVmFailedToCreateInvoice`
|
* @param failedToCreateInvoice `strings.receive.receiveVmFailedToCreateInvoice`
|
||||||
* @param withdrawRejected `strings.receiveVmWithdrawRejected`
|
* @param withdrawRejected `strings.receive.receiveVmWithdrawRejected`
|
||||||
* @param withdrawFailed `strings.receiveVmWithdrawFailed`
|
* @param withdrawFailed `strings.receive.receiveVmWithdrawFailed`
|
||||||
*/
|
*/
|
||||||
fun executeWithdraw(
|
fun executeWithdraw(
|
||||||
k1 : String,
|
k1 : String,
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import com.bitcointxoko.gudariwallet.LocalAppStrings
|
|||||||
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
|
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
|
||||||
import com.bitcointxoko.gudariwallet.ui.SendState
|
import com.bitcointxoko.gudariwallet.ui.SendState
|
||||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||||
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton
|
import com.bitcointxoko.gudariwallet.ui.common.CopyIconButton
|
||||||
import com.bitcointxoko.gudariwallet.ui.components.DetailRow
|
import com.bitcointxoko.gudariwallet.ui.common.DetailRow
|
||||||
import com.bitcointxoko.gudariwallet.util.BiometricHelper
|
import com.bitcointxoko.gudariwallet.util.BiometricHelper
|
||||||
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
import com.bitcointxoko.gudariwallet.util.fiatLabel
|
||||||
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
import com.bitcointxoko.gudariwallet.util.payingToLabel
|
||||||
@@ -37,6 +37,7 @@ import kotlinx.coroutines.delay
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun Bolt11ConfirmCard(
|
internal fun Bolt11ConfirmCard(
|
||||||
@@ -117,7 +118,7 @@ private fun ConfirmCardContent(
|
|||||||
var now by remember { mutableStateOf(Instant.now()) }
|
var now by remember { mutableStateOf(Instant.now()) }
|
||||||
LaunchedEffect(expiresAt) {
|
LaunchedEffect(expiresAt) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1_000L)
|
delay(1_000L.milliseconds)
|
||||||
now = Instant.now()
|
now = Instant.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user