initial commit
@@ -0,0 +1,83 @@
|
|||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
# If you prefer to keep some .idea config tracked, use these instead:
|
||||||
|
# .idea/caches
|
||||||
|
# .idea/libraries
|
||||||
|
# .idea/tasks.xml
|
||||||
|
# .idea/gradle.xml
|
||||||
|
# .idea/assetWizardSettings.xml
|
||||||
|
# .idea/dictionaries
|
||||||
|
# .idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Note: uncomment the following if you commit keystore files.
|
||||||
|
# *.jks
|
||||||
|
# *.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2+
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (if you use Firebase/Google APIs, keep this ignored
|
||||||
|
# and use a template like google-services.json.example instead)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
*.kotlin_module
|
||||||
|
|
||||||
|
# Fabric/Google Crashlytics
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Secrets / API keys
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
secrets.properties
|
||||||
|
api_keys.properties
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Lint reports
|
||||||
|
lint-results.xml
|
||||||
|
lint-results_files/
|
||||||
|
|
||||||
|
# APK signing info
|
||||||
|
signing.properties
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Gudari Wallet
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JavaCodeStyleSettings>
|
||||||
|
<option name="IMPORT_LAYOUT_TABLE">
|
||||||
|
<value>
|
||||||
|
<package name="android" withSubpackages="true" static="true" />
|
||||||
|
<package name="androidx" withSubpackages="true" static="true" />
|
||||||
|
<package name="com" withSubpackages="true" static="true" />
|
||||||
|
<package name="junit" withSubpackages="true" static="true" />
|
||||||
|
<package name="net" withSubpackages="true" static="true" />
|
||||||
|
<package name="org" withSubpackages="true" static="true" />
|
||||||
|
<package name="java" withSubpackages="true" static="true" />
|
||||||
|
<package name="javax" withSubpackages="true" static="true" />
|
||||||
|
<package name="" withSubpackages="true" static="true" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="android" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="androidx" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="com" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="junit" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="net" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="org" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="java" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="javax" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</JavaCodeStyleSettings>
|
||||||
|
<JetCodeStyleSettings>
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</JetCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="XML">
|
||||||
|
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
<arrangement>
|
||||||
|
<rules>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:android</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>xmlns:.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:id</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*:name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>name</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>style</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<rule>
|
||||||
|
<match>
|
||||||
|
<AND>
|
||||||
|
<NAME>.*</NAME>
|
||||||
|
<XML_ATTRIBUTE />
|
||||||
|
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||||
|
</AND>
|
||||||
|
</match>
|
||||||
|
<order>BY_NAME</order>
|
||||||
|
</rule>
|
||||||
|
</section>
|
||||||
|
</rules>
|
||||||
|
</arrangement>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="kotlin">
|
||||||
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<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>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<component name="ProjectDictionaryState">
|
||||||
|
<dictionary name="project">
|
||||||
|
<words>
|
||||||
|
<w>amboss</w>
|
||||||
|
<w>lnbc</w>
|
||||||
|
<w>msat</w>
|
||||||
|
<w>satoshis</w>
|
||||||
|
<w>sats</w>
|
||||||
|
</words>
|
||||||
|
</dictionary>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.gudariwallet"
|
||||||
|
compileSdk {
|
||||||
|
version = release(36) {
|
||||||
|
minorApiLevel = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.gudariwallet"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
// Networking
|
||||||
|
implementation(libs.retrofit2.retrofit)
|
||||||
|
implementation(libs.converter.gson)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.logging.interceptor)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
// Lifecycle / ViewModel
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
|
||||||
|
// Biometric
|
||||||
|
implementation(libs.androidx.biometric)
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
implementation(libs.gson)
|
||||||
|
|
||||||
|
// icons
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
|
||||||
|
// QR
|
||||||
|
implementation(libs.zxing.android.embedded)
|
||||||
|
|
||||||
|
//Navigation
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
|
// animations
|
||||||
|
implementation(libs.androidx.compose.animation)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.gudariwallet
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.example.gudariwallet", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<profileable
|
||||||
|
android:shell="true"
|
||||||
|
android:enabled="true" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="lightning" />
|
||||||
|
<data android:scheme="bitcoin" />
|
||||||
|
<data android:scheme="lnurlp" />
|
||||||
|
<data android:scheme="lnurlw" />
|
||||||
|
<data android:scheme="lnurlc" />
|
||||||
|
<data android:scheme="lnurl" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:stateNotNeeded="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden"
|
||||||
|
tools:replace="android:screenOrientation, android:theme" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.WalletNotificationService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.example.gudariwallet
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.example.gudariwallet.security.EncryptedSecretStore
|
||||||
|
import com.example.gudariwallet.service.WalletNotificationService
|
||||||
|
import com.example.gudariwallet.ui.OnboardingScreen
|
||||||
|
import com.example.gudariwallet.ui.WalletScreen
|
||||||
|
import com.example.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.example.gudariwallet.ui.WalletViewModelFactory
|
||||||
|
import com.example.gudariwallet.ui.theme.GudariWalletTheme
|
||||||
|
import com.example.gudariwallet.util.NotificationHelper
|
||||||
|
import com.example.gudariwallet.util.IntentUtils
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var secretStore: EncryptedSecretStore
|
||||||
|
|
||||||
|
private val vm: WalletViewModel by viewModels {
|
||||||
|
WalletViewModelFactory(application)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holds a paymentHash that arrived via notification tap.
|
||||||
|
// WalletScreen observes this and navigates to the detail screen.
|
||||||
|
// MutableState so Compose recomposes when it changes.
|
||||||
|
private val pendingPaymentHash = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
|
||||||
|
private val pendingPaymentUri = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
private val requestNotificationPermission =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ }
|
||||||
|
|
||||||
|
// Broadcasts window focus gains to any observer in the composition
|
||||||
|
val windowFocusEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasFocus)
|
||||||
|
if (hasFocus) windowFocusEvents.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
secretStore = EncryptedSecretStore(applicationContext)
|
||||||
|
|
||||||
|
// Must be first — before any notification is posted
|
||||||
|
NotificationHelper.createChannels(applicationContext)
|
||||||
|
|
||||||
|
// Read hash from the intent that launched the activity (cold start from notification)
|
||||||
|
pendingPaymentHash.value = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
|
||||||
|
// Read payment URI from the intent that launched the activity (cold start from deep link)
|
||||||
|
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
GudariWalletTheme {
|
||||||
|
var isOnboarded by remember { mutableStateOf(secretStore.isOnboarded) }
|
||||||
|
|
||||||
|
if (isOnboarded) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
WalletNotificationService.start(this@MainActivity)
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
promptBatteryOptimizationIfNeeded()
|
||||||
|
}
|
||||||
|
// Pass pendingPaymentHash so WalletScreen can consume it
|
||||||
|
WalletScreen(
|
||||||
|
vm = vm,
|
||||||
|
pendingPaymentHash = pendingPaymentHash,
|
||||||
|
pendingPaymentUri = pendingPaymentUri
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OnboardingScreen(
|
||||||
|
secretStore = secretStore,
|
||||||
|
onComplete = { isOnboarded = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the activity is already running and a notification is tapped
|
||||||
|
// (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created)
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
|
||||||
|
if (hash != null) {
|
||||||
|
pendingPaymentHash.value = hash
|
||||||
|
}
|
||||||
|
val uri = IntentUtils.extractPaymentUri(intent)
|
||||||
|
if (uri != null) {
|
||||||
|
pendingPaymentUri.value = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Permission / battery helpers (unchanged) ──────────────────────────────
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promptBatteryOptimizationIfNeeded() {
|
||||||
|
val pm = getSystemService(POWER_SERVICE) as PowerManager
|
||||||
|
if (pm.isIgnoringBatteryOptimizations(packageName)) return
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences("gudari_ui_prefs", MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean("battery_opt_prompted", false)) return
|
||||||
|
prefs.edit { putBoolean("battery_opt_prompted", true) }
|
||||||
|
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Enable background notifications")
|
||||||
|
.setMessage(
|
||||||
|
"To receive payment alerts when the app is closed, allow Gudari Wallet " +
|
||||||
|
"to run without battery restrictions.\n\n" +
|
||||||
|
"Tap 'Open Settings', then select Battery → Unrestricted."
|
||||||
|
)
|
||||||
|
.setPositiveButton("Open Settings") { _, _ -> openBatterySettings() }
|
||||||
|
.setNegativeButton("Not now", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openBatterySettings() {
|
||||||
|
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = "package:$packageName".toUri()
|
||||||
|
}
|
||||||
|
if (tryStartActivity(appDetailsIntent)) return
|
||||||
|
tryStartActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryStartActivity(intent: Intent): Boolean {
|
||||||
|
return try { startActivity(intent); true } catch (e: ActivityNotFoundException) { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.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,137 @@
|
|||||||
|
package com.example.gudariwallet.api
|
||||||
|
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Header
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
interface LNbitsApi {
|
||||||
|
|
||||||
|
/** GET /api/v1/wallet — returns balance and wallet info */
|
||||||
|
@GET("api/v1/wallet")
|
||||||
|
suspend fun getWallet(
|
||||||
|
@Header("X-Api-Key") apiKey: String
|
||||||
|
): WalletResponse
|
||||||
|
|
||||||
|
/** POST /api/v1/payments — creates a BOLT-11 invoice */
|
||||||
|
@POST("api/v1/payments")
|
||||||
|
suspend fun createInvoice(
|
||||||
|
@Header("X-Api-Key") apiKey: String,
|
||||||
|
@Body request: CreateInvoiceRequest
|
||||||
|
): CreateInvoiceResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/payments/{checking_id}
|
||||||
|
* Returns paid status and details for a specific payment hash.
|
||||||
|
* Auth: invoice key is sufficient.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/payments/{checking_id}")
|
||||||
|
suspend fun checkPayment(
|
||||||
|
@Header("X-Api-Key") apiKey: String,
|
||||||
|
@Path("checking_id") checkingId: String
|
||||||
|
): PaymentStatusResponse
|
||||||
|
|
||||||
|
/** POST /api/v1/payments/pay — pays a BOLT-11 invoice */
|
||||||
|
@POST("api/v1/payments")
|
||||||
|
suspend fun payInvoice(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Body request: PayInvoiceRequest
|
||||||
|
): PayInvoiceResponse
|
||||||
|
|
||||||
|
/** GET /api/v1/payments — returns payment history */
|
||||||
|
@GET("api/v1/payments")
|
||||||
|
suspend fun getPayments(
|
||||||
|
@Header("X-Api-Key") apiKey: String
|
||||||
|
): List<WsPayment>
|
||||||
|
|
||||||
|
/** POST /api/v1/payments/decode — returns decoded payment request */
|
||||||
|
@POST("api/v1/payments/decode")
|
||||||
|
suspend fun decodeInvoice(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Body request: DecodeInvoiceRequest
|
||||||
|
): DecodeInvoiceResponse
|
||||||
|
|
||||||
|
// Unified scanner — accepts LNURL, Lightning Address, OR bolt11
|
||||||
|
@GET("api/v1/lnurlscan/{code}")
|
||||||
|
suspend fun lnurlScan(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Path("code") code: String
|
||||||
|
): LnurlScanResponse
|
||||||
|
|
||||||
|
// Pay an LNURL-pay (after scanning)
|
||||||
|
@POST("api/v1/payments/lnurl")
|
||||||
|
suspend fun payLnurl(
|
||||||
|
@Header("X-Api-Key") adminKey: String,
|
||||||
|
@Body body: PayLnurlRequest
|
||||||
|
): PayLnurlResponse
|
||||||
|
|
||||||
|
// LNURL-withdraw callback (dynamic URL, per LUD-03)
|
||||||
|
@GET
|
||||||
|
suspend fun withdrawCallback(
|
||||||
|
@Url url: String
|
||||||
|
): WithdrawCallbackResponse
|
||||||
|
|
||||||
|
// Direct fetch of a raw LNURL metadata URL (LUD-17: lnurlp://, lnurlw://)
|
||||||
|
@GET
|
||||||
|
suspend fun fetchLnurlMetadata(
|
||||||
|
@Url url: String
|
||||||
|
): LnurlScanResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET {callback}?amount={msat}&comment={comment}
|
||||||
|
* Fetches a BOLT-11 invoice from an LNURL-pay callback URL directly.
|
||||||
|
* This is a raw @Url call — bypasses LNbits server-side proxy entirely.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
suspend fun fetchLnurlCallback(
|
||||||
|
@Url url: String
|
||||||
|
): LnurlCallbackResponse
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /lnurlp/api/v1/links?all_wallets=false
|
||||||
|
* Returns all LNURLp pay links for the current wallet.
|
||||||
|
* Auth: invoice key is sufficient.
|
||||||
|
*/
|
||||||
|
@GET("lnurlp/api/v1/links")
|
||||||
|
suspend fun getLnurlpLinks(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Query("all_wallets") allWallets: Boolean = false
|
||||||
|
): List<LnurlpLink>
|
||||||
|
|
||||||
|
// Get historical payments
|
||||||
|
@GET("api/v1/payments")
|
||||||
|
suspend fun getPayments(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Query("limit") limit: Int,
|
||||||
|
@Query("offset") offset: Int
|
||||||
|
): List<PaymentRecord>
|
||||||
|
|
||||||
|
// Get payment detail
|
||||||
|
@GET("api/v1/payments/{checking_id}")
|
||||||
|
suspend fun getPaymentDetail(
|
||||||
|
@Header("X-Api-Key") invoiceKey: String,
|
||||||
|
@Path("checking_id") checkingId: String
|
||||||
|
): PaymentDetailResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current BTC price in [currency].
|
||||||
|
* Example: GET /api/v1/rate/USD → {"rate": 43250.50}
|
||||||
|
* No X-Api-Key header needed.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/rate/{currency}")
|
||||||
|
suspend fun getFiatRate(
|
||||||
|
@Path("currency") currency: String
|
||||||
|
): FiatRateResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of supported ISO 4217 currency codes.
|
||||||
|
* Example: GET /api/v1/currencies → ["USD","EUR","GBP",...]
|
||||||
|
* No X-Api-Key header needed.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/currencies")
|
||||||
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.example.gudariwallet.api
|
||||||
|
|
||||||
|
import com.example.gudariwallet.BuildConfig
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object LNbitsClient {
|
||||||
|
|
||||||
|
// Single shared client — used by both Retrofit and WebSocket
|
||||||
|
val httpClient: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(WalletConstants.CONNECT_TIMEOUT_S, TimeUnit.SECONDS) // time to establish TCP connection
|
||||||
|
.readTimeout(WalletConstants.READ_TIMEOUT_S, TimeUnit.SECONDS) // time to wait for data (longer for WebSocket pings)
|
||||||
|
.writeTimeout(WalletConstants.WRITE_TIMEOUT_S, TimeUnit.SECONDS) // time to send a request body
|
||||||
|
.apply {
|
||||||
|
// Only log in debug builds — prevents API keys leaking in production logs
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
addInterceptor(HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun create(baseUrl: String): LNbitsApi {
|
||||||
|
val url = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(url)
|
||||||
|
.client(httpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
|
||||||
|
.build()
|
||||||
|
.create(LNbitsApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package com.example.gudariwallet.api
|
||||||
|
|
||||||
|
import com.google.gson.TypeAdapter
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.google.gson.stream.JsonReader
|
||||||
|
import com.google.gson.stream.JsonToken
|
||||||
|
import com.google.gson.stream.JsonWriter
|
||||||
|
|
||||||
|
// ── Wallet ────────────────────────────────────────────────────────────────────
|
||||||
|
data class WalletResponse(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val balance: Long // millisatoshis
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Create invoice ────────────────────────────────────────────────────────────
|
||||||
|
data class CreateInvoiceRequest(
|
||||||
|
val out: Boolean,
|
||||||
|
val amount: Long, // satoshis
|
||||||
|
val memo: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateInvoiceResponse(
|
||||||
|
@SerializedName("payment_hash") val paymentHash: String,
|
||||||
|
@SerializedName("payment_request") val paymentRequest: String,
|
||||||
|
@SerializedName("expiry") val expiry : String?
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Pay invoice ───────────────────────────────────────────────────────────────
|
||||||
|
data class PayInvoiceRequest(
|
||||||
|
val out: Boolean,
|
||||||
|
val bolt11: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayInvoiceResponse(
|
||||||
|
@SerializedName("payment_hash") val paymentHash: String,
|
||||||
|
@SerializedName("checking_id") val checkingId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Check payment ─────────────────────────────────────────────────────────────
|
||||||
|
data class PaymentStatusResponse(
|
||||||
|
val paid: Boolean,
|
||||||
|
val details: PaymentDetails? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentDetails(
|
||||||
|
val amount: Long?,
|
||||||
|
val fee: Long?,
|
||||||
|
val memo: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Decode BOLT-11 ────────────────────────────────────────────────────────────
|
||||||
|
data class DecodeInvoiceRequest(
|
||||||
|
val data: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DecodeInvoiceResponse(
|
||||||
|
@SerializedName("payment_hash") val paymentHash : String? = null,
|
||||||
|
@SerializedName("amount_msat") val amountMsat : Long? = null,
|
||||||
|
@SerializedName("description") val description : String? = null,
|
||||||
|
@SerializedName("payee") val payee : String? = null,
|
||||||
|
@SerializedName("route_hints") val routeHints : List<List<RouteHintHop>>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
data class RouteHintHop(
|
||||||
|
@SerializedName("public_key") val publicKey : String,
|
||||||
|
@SerializedName("short_channel_id") val shortChannelId : String? = null,
|
||||||
|
@SerializedName("base_fee") val baseFeeMsat : Long = 0,
|
||||||
|
@SerializedName("ppm_fee") val ppmFee : Long = 0,
|
||||||
|
@SerializedName("cltv_expiry_delta") val cltvExpiryDelta : Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LNURL scan ────────────────────────────────────────────────────────────────
|
||||||
|
data class LnurlScanResponse(
|
||||||
|
val tag: String?,
|
||||||
|
// ── payRequest fields ────────────────────────────────────────────────────
|
||||||
|
val callback: String? = null,
|
||||||
|
@SerializedName("minSendable") val minSendable: Long? = null,
|
||||||
|
@SerializedName("maxSendable") val maxSendable: Long? = null,
|
||||||
|
val metadata: String? = null,
|
||||||
|
@SerializedName("commentAllowed") val commentAllowed: Int? = null,
|
||||||
|
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
|
@SerializedName("nostrPubkey") val nostrPubkey: String? = null,
|
||||||
|
val bolt11: String? = null,
|
||||||
|
@SerializedName("amount_msat") val amountMsat: Long? = null,
|
||||||
|
val disposable: Boolean? = null, // LUD-06: null means treat as true (do not cache)
|
||||||
|
// ── withdrawRequest fields ───────────────────────────────────────────────
|
||||||
|
val k1 : String? = null,
|
||||||
|
@SerializedName("defaultDescription")
|
||||||
|
val defaultDescription: String? = null,
|
||||||
|
@SerializedName("minWithdrawable")
|
||||||
|
val minWithdrawable : Long? = null,
|
||||||
|
@SerializedName("maxWithdrawable")
|
||||||
|
val maxWithdrawable : Long? = null,
|
||||||
|
@SerializedName("balanceCheck")
|
||||||
|
val balanceCheck : String? = null,
|
||||||
|
@SerializedName("currentBalance")
|
||||||
|
val currentBalance : Long? = null,
|
||||||
|
// ── Error fields ─────────────────────────────────────────────────────────
|
||||||
|
val status : String? = null, // "OK" or "ERROR"
|
||||||
|
val reason : String? = null // error message when status == "ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LNURL-pay callback response (LUD-06) ─────────────────────────────────────
|
||||||
|
data class LnurlCallbackResponse(
|
||||||
|
val pr : String, // BOLT-11 invoice to pay
|
||||||
|
val routes : List<Any>? = null,
|
||||||
|
@SerializedName("successAction")
|
||||||
|
val successAction : SuccessAction? = null,
|
||||||
|
// Error fields (server may return status=ERROR instead of pr)
|
||||||
|
val status : String? = null,
|
||||||
|
val reason : String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// ── LNURL pay ─────────────────────────────────────────────────────────────────
|
||||||
|
data class LnurlPayRes(
|
||||||
|
val tag: String?,
|
||||||
|
val callback: String?,
|
||||||
|
@SerializedName("minSendable") val minSendable: Long?,
|
||||||
|
@SerializedName("maxSendable") val maxSendable: Long?,
|
||||||
|
val metadata: String?,
|
||||||
|
@SerializedName("commentAllowed") val commentAllowed: Int?,
|
||||||
|
@SerializedName("allowsNostr") val allowsNostr: Boolean? = null,
|
||||||
|
@SerializedName("nostrPubkey") val nostrPubkey: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayLnurlRequest(
|
||||||
|
val res: LnurlPayRes,
|
||||||
|
val lnurl: String,
|
||||||
|
val amount: Long,
|
||||||
|
val comment: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PayLnurlResponse(
|
||||||
|
@SerializedName("payment_hash") val paymentHash: String,
|
||||||
|
@SerializedName("checking_id") val checkingId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WithdrawCallbackResponse(
|
||||||
|
val status : String,
|
||||||
|
val reason : String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LNURLp pay link (lightning address) ──────────────────────────────────────
|
||||||
|
data class LnurlpLink(
|
||||||
|
val id : String,
|
||||||
|
val wallet : String,
|
||||||
|
val username : String?, // the part before the @ in the lightning address
|
||||||
|
val description : String?,
|
||||||
|
val min : Long,
|
||||||
|
val max : Long
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// ── Payment history ───────────────────────────────────────────────────────────
|
||||||
|
data class PaymentRecord(
|
||||||
|
@SerializedName("payment_hash") val paymentHash: String,
|
||||||
|
@SerializedName("checking_id") val checkingId: String,
|
||||||
|
@SerializedName("amount") val amountMsat: Long,
|
||||||
|
@SerializedName("fee") val feeMsat: Long,
|
||||||
|
@SerializedName("memo") val memo: String?,
|
||||||
|
@SerializedName("time") val time: String,
|
||||||
|
@SerializedName("status") val status: String,
|
||||||
|
@SerializedName("bolt11") val bolt11: String?,
|
||||||
|
@SerializedName("pending") val pending: Boolean,
|
||||||
|
@SerializedName("preimage") val preimage: String?,
|
||||||
|
@SerializedName("extra") val extra: PaymentExtra?
|
||||||
|
) {
|
||||||
|
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
|
||||||
|
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
|
||||||
|
val isOutgoing: Boolean get() = amountMsat < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PaymentExtra(
|
||||||
|
@SerializedName("tag") val tag: String?,
|
||||||
|
@SerializedName("comment") val comment: String?,
|
||||||
|
@SerializedName("success_action") val successAction: SuccessAction?,
|
||||||
|
@SerializedName("wallet_id") val walletId: String?,
|
||||||
|
@SerializedName("destination") val destination: String?,
|
||||||
|
@SerializedName("expires_at") val expiresAt: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SuccessAction(
|
||||||
|
@SerializedName("tag") val tag: String?,
|
||||||
|
@SerializedName("message") val message: String?,
|
||||||
|
@SerializedName("url") val url: String?,
|
||||||
|
@SerializedName("description") val description: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNbits returns `success_action` as either:
|
||||||
|
* - a JSON object {"tag":"message","message":"Thanks!"}
|
||||||
|
* - a plain string "Thanks!"
|
||||||
|
* - null
|
||||||
|
*
|
||||||
|
* Gson's default deserialiser throws IllegalStateException when it sees a
|
||||||
|
* string where it expects {. This adapter handles all three cases.
|
||||||
|
*
|
||||||
|
* Registered centrally in [GsonProvider] — applies to all deserialization
|
||||||
|
* paths automatically.
|
||||||
|
*/
|
||||||
|
class SuccessActionAdapter : TypeAdapter<SuccessAction?>() {
|
||||||
|
|
||||||
|
override fun write(out: JsonWriter, value: SuccessAction?) {
|
||||||
|
if (value == null) { out.nullValue(); return }
|
||||||
|
out.beginObject()
|
||||||
|
out.name("tag"); out.value(value.tag)
|
||||||
|
out.name("message"); out.value(value.message)
|
||||||
|
out.name("url"); out.value(value.url)
|
||||||
|
out.name("description"); out.value(value.description)
|
||||||
|
out.endObject()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(reader: JsonReader): SuccessAction? {
|
||||||
|
return when (reader.peek()) {
|
||||||
|
JsonToken.NULL -> { reader.nextNull(); null }
|
||||||
|
|
||||||
|
// Plain string — treat as a message-type success action
|
||||||
|
JsonToken.STRING -> {
|
||||||
|
val text = reader.nextString()
|
||||||
|
if (text.isBlank()) null
|
||||||
|
else SuccessAction(tag = "message", message = text, url = null, description = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal object
|
||||||
|
JsonToken.BEGIN_OBJECT -> {
|
||||||
|
reader.beginObject()
|
||||||
|
var tag: String? = null
|
||||||
|
var message: String? = null
|
||||||
|
var url: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
while (reader.hasNext()) {
|
||||||
|
when (reader.nextName()) {
|
||||||
|
"tag" -> tag = reader.nextString()
|
||||||
|
"message" -> message = reader.nextString()
|
||||||
|
"url" -> url = reader.nextString()
|
||||||
|
"description" -> description = reader.nextString()
|
||||||
|
else -> reader.skipValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.endObject()
|
||||||
|
SuccessAction(tag = tag, message = message, url = url, description = description)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> { reader.skipValue(); null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data class PaymentDetailResponse(
|
||||||
|
val paid: Boolean,
|
||||||
|
val details: PaymentRecord?
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── WebSocket payment message ─────────────────────────────────────────────────
|
||||||
|
data class WsPaymentMessage(
|
||||||
|
val payment: WsPayment?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WsPayment(
|
||||||
|
@SerializedName("payment_hash") val paymentHash: String,
|
||||||
|
val amount: Long,
|
||||||
|
val status: String,
|
||||||
|
val memo: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Fiat rate ────────────────────────────────────────────────────────────────
|
||||||
|
data class FiatRateResponse(
|
||||||
|
val rate: Double, // sat to fiat rate
|
||||||
|
val price : Double // BTC price in the requested fiat currency
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.gudariwallet.api
|
||||||
|
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
// ── mempool.space ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class MempoolNodeResponse(
|
||||||
|
val alias: String?,
|
||||||
|
val public_key: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
interface MempoolApi {
|
||||||
|
@GET("api/v1/lightning/nodes/{pubkey}")
|
||||||
|
suspend fun getNode(@Path("pubkey") pubkey: String): MempoolNodeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1ml.com ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class OneMlNodeResponse(
|
||||||
|
val alias: String?,
|
||||||
|
val pub_key: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
interface OneMlApi {
|
||||||
|
@GET("node/{pubkey}/json")
|
||||||
|
suspend fun getNode(@Path("pubkey") pubkey: String): OneMlNodeResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.gudariwallet.api
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a Lightning node pubkey to a human-readable alias.
|
||||||
|
* Tries 1ml.com first, falls back to mempool.space.
|
||||||
|
* Returns null if neither source has the node or both fail.
|
||||||
|
*/
|
||||||
|
class NodeAliasService {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val mempoolApi: MempoolApi = Retrofit.Builder()
|
||||||
|
.baseUrl("https://mempool.space/")
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(MempoolApi::class.java)
|
||||||
|
|
||||||
|
private val oneMlApi: OneMlApi = Retrofit.Builder()
|
||||||
|
.baseUrl("https://1ml.com/")
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(OneMlApi::class.java)
|
||||||
|
|
||||||
|
suspend fun resolveAlias(pubkey: String): String? {
|
||||||
|
if (pubkey.isBlank()) return null
|
||||||
|
|
||||||
|
// 1. Try 1ml.com
|
||||||
|
runCatching { oneMlApi.getNode(pubkey) }
|
||||||
|
.onSuccess { response ->
|
||||||
|
val alias = response.alias?.takeIf { it.isNotBlank() }
|
||||||
|
if (alias != null) return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to mempool.space
|
||||||
|
runCatching { mempoolApi.getNode(pubkey) }
|
||||||
|
.onSuccess { response ->
|
||||||
|
val alias = response.alias?.takeIf { it.isNotBlank() }
|
||||||
|
if (alias != null) return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import com.example.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.example.gudariwallet.api.RouteHintHop
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain model classes for the wallet layer.
|
||||||
|
*
|
||||||
|
* These are the types returned by [WalletRepository] methods — deliberately
|
||||||
|
* decoupled from the raw API response models in [com.example.gudariwallet.api].
|
||||||
|
*/
|
||||||
|
|
||||||
|
data class WalletBalance(
|
||||||
|
val sats: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatedInvoice(
|
||||||
|
val bolt11 : String,
|
||||||
|
val paymentHash : String,
|
||||||
|
val expiry : String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DecodedBolt11(
|
||||||
|
val amountSats : Long,
|
||||||
|
val memo : String,
|
||||||
|
val payee : String?,
|
||||||
|
val routeHints : List<RouteHintHop> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ScannedLnurl(
|
||||||
|
val tag : String?,
|
||||||
|
val callback : String?,
|
||||||
|
val minSats : Long,
|
||||||
|
val maxSats : Long,
|
||||||
|
val description : String,
|
||||||
|
val domain : String,
|
||||||
|
val commentAllowed : Int,
|
||||||
|
val rawRes : LnurlScanResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PaymentConfirmation(
|
||||||
|
val paymentHash: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import com.example.gudariwallet.service.NodeAliasService
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [NodeAliasRepository] backed by [NodeAliasService], which queries
|
||||||
|
* mempool.space then 1ml.com and caches results in SharedPreferences.
|
||||||
|
*/
|
||||||
|
class LNbitsNodeAliasRepository(
|
||||||
|
private val service: NodeAliasService
|
||||||
|
) : NodeAliasRepository {
|
||||||
|
|
||||||
|
override val aliases: StateFlow<Map<String, String>>
|
||||||
|
get() = service.aliases
|
||||||
|
|
||||||
|
override suspend fun resolve(pubkey: String): String? =
|
||||||
|
service.resolve(pubkey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.gudariwallet.api.*
|
||||||
|
import com.example.gudariwallet.api.LNbitsClient.httpClient
|
||||||
|
import com.example.gudariwallet.security.SecretStore
|
||||||
|
import com.example.gudariwallet.util.LnurlBech32
|
||||||
|
import com.example.gudariwallet.util.LnurlMetadataParser
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
|
||||||
|
private const val TAG = "LNbitsWalletRepo"
|
||||||
|
|
||||||
|
class LNbitsWalletRepository(
|
||||||
|
private val api: LNbitsApi,
|
||||||
|
private val secrets: SecretStore,
|
||||||
|
private val context : Context,
|
||||||
|
private val nodeAliasService: com.example.gudariwallet.service.NodeAliasService =
|
||||||
|
com.example.gudariwallet.service.NodeAliasService(httpClient, context),
|
||||||
|
) : WalletRepository {
|
||||||
|
|
||||||
|
// ── Balance ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun getBalance(): WalletBalance {
|
||||||
|
val response = api.getWallet(secrets.invoiceKey)
|
||||||
|
return WalletBalance(sats = response.balance / WalletConstants.MSAT_PER_SAT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Receive ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice {
|
||||||
|
val response = api.createInvoice(
|
||||||
|
secrets.invoiceKey,
|
||||||
|
CreateInvoiceRequest(
|
||||||
|
out = false,
|
||||||
|
amount = amountSats,
|
||||||
|
memo = memo.ifBlank { "Gudari Wallet" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CreatedInvoice(
|
||||||
|
bolt11 = response.paymentRequest,
|
||||||
|
paymentHash = response.paymentHash,
|
||||||
|
expiry = response.expiry
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isPaymentReceived(paymentHash: String): Boolean {
|
||||||
|
return api.checkPayment(secrets.invoiceKey, paymentHash).paid
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 {
|
||||||
|
val response = api.decodeInvoice(secrets.invoiceKey, DecodeInvoiceRequest(data = bolt11))
|
||||||
|
val amountSats = response.amountMsat
|
||||||
|
?.takeIf { it > 0L }
|
||||||
|
?.div(WalletConstants.MSAT_PER_SAT)
|
||||||
|
?: throw UnsupportedOperationException(
|
||||||
|
"Zero-amount invoices are not supported. Please ask the sender to specify an amount."
|
||||||
|
)
|
||||||
|
return DecodedBolt11(
|
||||||
|
amountSats = amountSats,
|
||||||
|
memo = response.description ?: "",
|
||||||
|
payee = response.payee?.takeIf { it.isNotBlank() },
|
||||||
|
routeHints = response.routeHints?.flatten() ?: emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override suspend fun scanLnurl(code: String): ScannedLnurl {
|
||||||
|
val r = api.lnurlScan(secrets.invoiceKey, code)
|
||||||
|
return ScannedLnurl(
|
||||||
|
tag = r.tag,
|
||||||
|
callback = r.callback,
|
||||||
|
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
|
||||||
|
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
|
||||||
|
description = LnurlMetadataParser.parseDescription(r.metadata),
|
||||||
|
domain = LnurlMetadataParser.extractDomain(r.callback),
|
||||||
|
commentAllowed = r.commentAllowed ?: 0,
|
||||||
|
rawRes = r
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse {
|
||||||
|
return api.fetchLnurlMetadata(httpsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side LNURL/Lightning Address resolution.
|
||||||
|
* - Lightning Address (user@domain) → GET https://domain/.well-known/lnurlp/user
|
||||||
|
* - Bech32 LNURL → decode → GET decoded https URL
|
||||||
|
* Throws on any failure so the caller can fall back to the server proxy.
|
||||||
|
*/
|
||||||
|
override suspend fun scanLnurlDirect(code: String): ScannedLnurl {
|
||||||
|
val httpsUrl = when {
|
||||||
|
// Lightning Address: user@domain.com
|
||||||
|
'@' in code -> {
|
||||||
|
val parts = code.trim().split("@")
|
||||||
|
require(parts.size == 2) { "Invalid Lightning Address: $code" }
|
||||||
|
val (user, domain) = parts
|
||||||
|
"https://$domain/.well-known/lnurlp/$user"
|
||||||
|
}
|
||||||
|
// Bech32 LNURL: decode to https URL
|
||||||
|
else -> {
|
||||||
|
LnurlBech32.decode(code)
|
||||||
|
?: throw IllegalArgumentException("Could not decode LNURL: $code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "LNURL [DIRECT SCAN] $code → $httpsUrl")
|
||||||
|
val r = api.fetchLnurlMetadata(httpsUrl)
|
||||||
|
|
||||||
|
return ScannedLnurl(
|
||||||
|
tag = r.tag,
|
||||||
|
callback = r.callback,
|
||||||
|
minSats = (r.minSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
|
||||||
|
maxSats = (r.maxSendable ?: 0L) / WalletConstants.MSAT_PER_SAT,
|
||||||
|
description = LnurlMetadataParser.parseDescription(r.metadata),
|
||||||
|
domain = LnurlMetadataParser.extractDomain(r.callback),
|
||||||
|
commentAllowed = r.commentAllowed ?: 0,
|
||||||
|
rawRes = r
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side LNURL-pay: fetches the invoice from the callback URL directly,
|
||||||
|
* then pays it via the standard bolt11 path.
|
||||||
|
* Throws on any failure so the caller can fall back to the server proxy.
|
||||||
|
*/
|
||||||
|
override suspend fun payLnurlDirect(
|
||||||
|
rawRes : LnurlScanResponse,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment : String?
|
||||||
|
): PaymentConfirmation {
|
||||||
|
val bolt11 = fetchLnurlCallbackInvoice(rawRes, amountMsat, comment)
|
||||||
|
Log.d(TAG, "LNURL [DIRECT PAY ] got invoice, paying bolt11")
|
||||||
|
return payBolt11(bolt11)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the bolt11 invoice from the LNURL callback URL without paying it.
|
||||||
|
* Reuses the same URL-building and error-handling logic as [payLnurlDirect],
|
||||||
|
* but returns the raw `pr` string instead of proceeding to payment.
|
||||||
|
*/
|
||||||
|
override suspend fun fetchLnurlCallbackInvoice(
|
||||||
|
rawRes : LnurlScanResponse,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment : String?
|
||||||
|
): String {
|
||||||
|
val callback = requireNotNull(rawRes.callback) { "LNURL callback is null" }
|
||||||
|
val callbackUrl = buildString {
|
||||||
|
append(callback)
|
||||||
|
append(if ('?' in callback) '&' else '?')
|
||||||
|
append("amount=")
|
||||||
|
append(amountMsat)
|
||||||
|
if (!comment.isNullOrBlank()) {
|
||||||
|
append("&comment=")
|
||||||
|
append(android.net.Uri.encode(comment))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl")
|
||||||
|
val callbackResponse = api.fetchLnurlCallback(callbackUrl)
|
||||||
|
if (callbackResponse.status == "ERROR") {
|
||||||
|
throw RuntimeException(
|
||||||
|
callbackResponse.reason ?: "LNURL callback returned an error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return callbackResponse.pr.ifBlank {
|
||||||
|
throw RuntimeException("LNURL callback returned an empty invoice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun payBolt11(bolt11: String): PaymentConfirmation {
|
||||||
|
val response = api.payInvoice(
|
||||||
|
secrets.adminKey,
|
||||||
|
PayInvoiceRequest(out = true, bolt11 = bolt11)
|
||||||
|
)
|
||||||
|
return PaymentConfirmation(paymentHash = response.paymentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun payLnurl(
|
||||||
|
rawRes: LnurlScanResponse,
|
||||||
|
lnurl: String,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment: String?
|
||||||
|
): PaymentConfirmation {
|
||||||
|
val response = api.payLnurl(
|
||||||
|
secrets.adminKey,
|
||||||
|
PayLnurlRequest(
|
||||||
|
res = LnurlPayRes(
|
||||||
|
tag = rawRes.tag,
|
||||||
|
callback = rawRes.callback,
|
||||||
|
minSendable = rawRes.minSendable,
|
||||||
|
maxSendable = rawRes.maxSendable,
|
||||||
|
metadata = rawRes.metadata,
|
||||||
|
commentAllowed = rawRes.commentAllowed,
|
||||||
|
allowsNostr = rawRes.allowsNostr,
|
||||||
|
nostrPubkey = rawRes.nostrPubkey
|
||||||
|
),
|
||||||
|
lnurl = lnurl,
|
||||||
|
amount = amountMsat,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return PaymentConfirmation(paymentHash = response.paymentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun executeWithdraw(
|
||||||
|
callback: String,
|
||||||
|
k1 : String,
|
||||||
|
bolt11 : String
|
||||||
|
): WithdrawCallbackResponse {
|
||||||
|
val url = buildString {
|
||||||
|
append(callback)
|
||||||
|
append(if ('?' in callback) '&' else '?')
|
||||||
|
append("k1=")
|
||||||
|
append(Uri.encode(k1))
|
||||||
|
append("&pr=")
|
||||||
|
append(Uri.encode(bolt11))
|
||||||
|
}
|
||||||
|
Log.d(TAG, "LNURL-WITHDRAW [CALLBACK] $url")
|
||||||
|
return api.withdrawCallback(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLightningAddress(): String? {
|
||||||
|
val links = api.getLnurlpLinks(secrets.invoiceKey, allWallets = false)
|
||||||
|
val username = links.firstOrNull { !it.username.isNullOrBlank() }?.username
|
||||||
|
?: return null
|
||||||
|
val domain = secrets.baseUrl
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("http://")
|
||||||
|
.trimEnd('/')
|
||||||
|
.substringBefore("/")
|
||||||
|
return "$username@$domain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fiat ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun getFiatRate(currency: String): Double {
|
||||||
|
Log.d(TAG, "FIAT [RATE FETCH ] currency=$currency")
|
||||||
|
return api.getFiatRate(currency).rate
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSupportedCurrencies(): List<String> {
|
||||||
|
Log.d(TAG, "FIAT [CURRENCIES] fetching supported currency list")
|
||||||
|
return api.getSupportedCurrencies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> {
|
||||||
|
return api.getPayments(secrets.invoiceKey, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
||||||
|
return api.getPaymentDetail(secrets.invoiceKey, checkingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
private const val TAG = "LnurlCacheRepository"
|
||||||
|
|
||||||
|
data class LnurlCacheEntry(
|
||||||
|
val key : String,
|
||||||
|
val response : LnurlScanResponse,
|
||||||
|
val savedAt : Long
|
||||||
|
)
|
||||||
|
|
||||||
|
class LnurlCacheRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
WalletConstants.LNURL_CACHE_PREFS_NAME, Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
// In-memory map for fast access within a session
|
||||||
|
private val memCache = mutableMapOf<String, LnurlCacheEntry>()
|
||||||
|
|
||||||
|
init { loadFromDisk() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cached [LnurlScanResponse] for [key], or null if:
|
||||||
|
* - no entry exists
|
||||||
|
* - the entry is older than [WalletConstants.LNURL_CACHE_TTL_MS]
|
||||||
|
* - the server marked the endpoint as disposable (null is treated as true per LUD-06)
|
||||||
|
*/
|
||||||
|
fun get(key: String): LnurlScanResponse? {
|
||||||
|
val entry = memCache[key] ?: return null
|
||||||
|
val ageMs = System.currentTimeMillis() - entry.savedAt
|
||||||
|
|
||||||
|
if (ageMs > WalletConstants.LNURL_CACHE_TTL_MS) {
|
||||||
|
Log.d(TAG, "LNURL [CACHE STALE] $key (age ${ageMs / 1000}s)")
|
||||||
|
memCache.remove(key)
|
||||||
|
persistToDisk()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// LUD-06: disposable == null means treat as true — do not serve from cache
|
||||||
|
val disposable = entry.response.disposable
|
||||||
|
if (disposable == null || disposable == true) {
|
||||||
|
Log.d(TAG, "LNURL [DISPOSABLE ] $key — not serving from cache (disposable=$disposable)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "LNURL [CACHE HIT ] $key (age ${ageMs / 1000}s)")
|
||||||
|
return entry.response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores [response] under [key].
|
||||||
|
* Silently skips if the response is marked disposable (or disposable is null).
|
||||||
|
*/
|
||||||
|
fun put(key: String, response: LnurlScanResponse) {
|
||||||
|
val disposable = response.disposable
|
||||||
|
if (disposable == null || disposable == true) {
|
||||||
|
Log.d(TAG, "LNURL [SKIP CACHE ] $key — disposable=$disposable, not caching")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val entry = LnurlCacheEntry(key, response, System.currentTimeMillis())
|
||||||
|
memCache[key] = entry
|
||||||
|
persistToDisk()
|
||||||
|
Log.d(TAG, "LNURL [CACHE SET ] $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the entry for [key] from both memory and disk.
|
||||||
|
* Called when a payment attempt with cached data fails, so the next
|
||||||
|
* scan will always fetch fresh metadata from the server.
|
||||||
|
*/
|
||||||
|
fun invalidate(key: String) {
|
||||||
|
if (memCache.remove(key) != null) {
|
||||||
|
persistToDisk()
|
||||||
|
Log.d(TAG, "LNURL [INVALIDATED] $key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
memCache.clear()
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
Log.d(TAG, "LNURL [CLEARED ] all entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
val json = prefs.getString(WalletConstants.LNURL_CACHE_PREFS_KEY, null) ?: return
|
||||||
|
runCatching {
|
||||||
|
val type = object : TypeToken<List<LnurlCacheEntry>>() {}.type
|
||||||
|
val list: List<LnurlCacheEntry> = gson.fromJson(json, type)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var loaded = 0
|
||||||
|
list.forEach { entry ->
|
||||||
|
if (now - entry.savedAt < WalletConstants.LNURL_CACHE_TTL_MS) {
|
||||||
|
memCache[entry.key] = entry
|
||||||
|
loaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(TAG, "LNURL [LOADED ] $loaded valid entries from disk (${list.size - loaded} expired)")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "LNURL [LOAD ERR ] ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistToDisk() {
|
||||||
|
runCatching {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(WalletConstants.LNURL_CACHE_PREFS_KEY, gson.toJson(memCache.values.toList()))
|
||||||
|
.apply()
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "LNURL [PERSIST ERR] ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias resolution concern, separated from the payment/wallet domain.
|
||||||
|
*
|
||||||
|
* Implementations are expected to cache results (in-memory and/or on disk)
|
||||||
|
* and expose the full resolved alias map as a [StateFlow] for observers.
|
||||||
|
*/
|
||||||
|
interface NodeAliasRepository {
|
||||||
|
/** Live map of pubkey → resolved alias for all pubkeys resolved so far. */
|
||||||
|
val aliases: StateFlow<Map<String, String>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the human-readable alias for [pubkey].
|
||||||
|
* Returns the alias string, or null if resolution fails.
|
||||||
|
* Results are cached — repeated calls for the same pubkey are cheap.
|
||||||
|
*/
|
||||||
|
suspend fun resolve(pubkey: String): String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.gudariwallet.api.GsonProvider
|
||||||
|
import com.example.gudariwallet.api.PaymentDetailResponse
|
||||||
|
import com.example.gudariwallet.api.PaymentRecord
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import com.example.gudariwallet.api.SuccessAction
|
||||||
|
import com.example.gudariwallet.api.SuccessActionAdapter
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
private const val TAG = "PaymentCacheRepository"
|
||||||
|
|
||||||
|
class PaymentCacheRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
WalletConstants.PAYMENTS_PREFS_NAME, Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
private val gson = GsonProvider.gson
|
||||||
|
// ── In-memory detail cache — immutable once confirmed, no TTL needed ──────
|
||||||
|
private val detailCache = mutableMapOf<String, PaymentDetailResponse>()
|
||||||
|
|
||||||
|
// ── Payment list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Load the persisted first-page list. Returns empty list if nothing cached or cache is stale. */
|
||||||
|
fun loadCachedPayments(): List<PaymentRecord> {
|
||||||
|
val json = prefs.getString(WalletConstants.PAYMENTS_PREFS_KEY, null) ?: return emptyList()
|
||||||
|
val savedAt = prefs.getLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, 0L)
|
||||||
|
val ageMs = System.currentTimeMillis() - savedAt
|
||||||
|
|
||||||
|
if (ageMs > WalletConstants.PAYMENTS_CACHE_TTL_MS) {
|
||||||
|
Log.d(TAG, "PAYMENTS [CACHE STALE] age ${ageMs / 1000}s — ignoring persisted list")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val type: java.lang.reflect.Type = object : TypeToken<List<PaymentRecord>>() {}.type
|
||||||
|
val list: List<PaymentRecord> = gson.fromJson(json, type)
|
||||||
|
Log.d(TAG, "PAYMENTS [CACHE HIT ] loaded ${list.size} payments (age ${ageMs / 1000}s)")
|
||||||
|
list
|
||||||
|
}.getOrElse {
|
||||||
|
Log.w(TAG, "PAYMENTS [CACHE ERR ] failed to deserialise: ${it.message}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the first page of payments to SharedPreferences. */
|
||||||
|
fun savePayments(payments: List<PaymentRecord>) {
|
||||||
|
runCatching {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(WalletConstants.PAYMENTS_PREFS_KEY, gson.toJson(payments))
|
||||||
|
.putLong(WalletConstants.PAYMENTS_PREFS_SAVED_AT, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
Log.d(TAG, "PAYMENTS [PERSIST ] saved ${payments.size} payments")
|
||||||
|
}.onFailure {
|
||||||
|
Log.w(TAG, "PAYMENTS [PERSIST ] write failed: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns a cached detail response, or null if not yet fetched this session. */
|
||||||
|
fun getCachedDetail(checkingId: String): PaymentDetailResponse? {
|
||||||
|
return detailCache[checkingId]?.also {
|
||||||
|
Log.d(TAG, "PAYMENTS [DETAIL HIT] $checkingId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store a detail response in the in-memory cache. */
|
||||||
|
fun saveDetail(checkingId: String, detail: PaymentDetailResponse) {
|
||||||
|
detailCache[checkingId] = detail
|
||||||
|
Log.d(TAG, "PAYMENTS [DETAIL SET] $checkingId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear everything — useful for testing and logout. */
|
||||||
|
fun clearAll() {
|
||||||
|
detailCache.clear()
|
||||||
|
prefs.edit()
|
||||||
|
.remove(WalletConstants.PAYMENTS_PREFS_KEY)
|
||||||
|
.remove(WalletConstants.PAYMENTS_PREFS_SAVED_AT)
|
||||||
|
.apply()
|
||||||
|
Log.d(TAG, "PAYMENTS [CLEARED ] cache wiped")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.gudariwallet.data
|
||||||
|
|
||||||
|
import com.example.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.example.gudariwallet.api.PaymentDetailResponse
|
||||||
|
import com.example.gudariwallet.api.PaymentRecord
|
||||||
|
import com.example.gudariwallet.api.WithdrawCallbackResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for all wallet operations.
|
||||||
|
* Domain model types are defined in [DomainModels.kt].
|
||||||
|
*/
|
||||||
|
interface WalletRepository {
|
||||||
|
suspend fun getBalance(): WalletBalance
|
||||||
|
suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice
|
||||||
|
suspend fun isPaymentReceived(paymentHash: String): Boolean
|
||||||
|
suspend fun decodeBolt11(bolt11: String): DecodedBolt11
|
||||||
|
suspend fun scanLnurl(code: String): ScannedLnurl
|
||||||
|
suspend fun fetchLnurlDirect(httpsUrl: String): LnurlScanResponse
|
||||||
|
suspend fun scanLnurlDirect(code: String): ScannedLnurl
|
||||||
|
suspend fun payLnurlDirect(
|
||||||
|
rawRes : LnurlScanResponse,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment : String?
|
||||||
|
): PaymentConfirmation
|
||||||
|
suspend fun fetchLnurlCallbackInvoice(
|
||||||
|
rawRes: LnurlScanResponse,
|
||||||
|
amountMsat: Long,
|
||||||
|
comment: String?
|
||||||
|
): String
|
||||||
|
suspend fun payBolt11(bolt11: String): PaymentConfirmation
|
||||||
|
suspend fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?): PaymentConfirmation
|
||||||
|
suspend fun executeWithdraw(
|
||||||
|
callback: String,
|
||||||
|
k1 : String,
|
||||||
|
bolt11 : String
|
||||||
|
): WithdrawCallbackResponse
|
||||||
|
suspend fun getLightningAddress(): String?
|
||||||
|
suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord>
|
||||||
|
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||||
|
suspend fun getFiatRate(currency: String): Double
|
||||||
|
suspend fun getSupportedCurrencies(): List<String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.gudariwallet.security
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import android.util.Base64
|
||||||
|
|
||||||
|
object SecretManager {
|
||||||
|
private const val KEY_ALIAS = "lnbits_api_key"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val GCM_TAG_LENGTH = 128
|
||||||
|
|
||||||
|
/** Generate (or retrieve) the AES key in the Keystore */
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||||
|
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
|
||||||
|
|
||||||
|
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||||
|
keyGen.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
// Require device lock screen to be set up
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return keyGen.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt a plaintext string; returns Base64(IV + ciphertext) */
|
||||||
|
fun encrypt(plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val iv = cipher.iv
|
||||||
|
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
val combined = iv + ciphertext
|
||||||
|
return Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a Base64(IV + ciphertext) string */
|
||||||
|
fun decrypt(encoded: String): String {
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
val iv = combined.copyOfRange(0, 12)
|
||||||
|
val ciphertext = combined.copyOfRange(12, combined.size)
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_LENGTH, iv))
|
||||||
|
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.gudariwallet.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
|
||||||
|
|
||||||
|
// Interface — lets you swap in a FakeSecretStore in tests
|
||||||
|
interface SecretStore {
|
||||||
|
val invoiceKey: String
|
||||||
|
val adminKey: String
|
||||||
|
val baseUrl: String
|
||||||
|
val isOnboarded: Boolean
|
||||||
|
fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptedSecretStore(context: Context) : SecretStore {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences(WalletConstants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
override val invoiceKey: String
|
||||||
|
get() = decryptOrEmpty(WalletConstants.PREFS_INVOICE_KEY_ENC)
|
||||||
|
|
||||||
|
override val adminKey: String
|
||||||
|
get() = decryptOrEmpty(WalletConstants.PREFS_ADMIN_KEY_ENC)
|
||||||
|
|
||||||
|
override val baseUrl: String
|
||||||
|
get() = prefs.getString(WalletConstants.PREFS_BASE_URL, "").orEmpty()
|
||||||
|
|
||||||
|
override val isOnboarded: Boolean
|
||||||
|
get() = prefs.getBoolean(WalletConstants.PREFS_ONBOARDED, false)
|
||||||
|
|
||||||
|
override fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(WalletConstants.PREFS_BASE_URL, baseUrl)
|
||||||
|
.putString(WalletConstants.PREFS_INVOICE_KEY_ENC, SecretManager.encrypt(invoiceKey))
|
||||||
|
.putString(WalletConstants.PREFS_ADMIN_KEY_ENC, SecretManager.encrypt(adminKey))
|
||||||
|
.putBoolean(WalletConstants.PREFS_ONBOARDED, true)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
private fun decryptOrEmpty(key: String): String {
|
||||||
|
val enc = prefs.getString(key, "").orEmpty()
|
||||||
|
if (enc.isBlank()) return ""
|
||||||
|
return runCatching { SecretManager.decrypt(enc) }.getOrDefault("")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package com.example.gudariwallet.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private const val TAG = "NodeAliasService"
|
||||||
|
|
||||||
|
class NodeAliasService(
|
||||||
|
private val httpClient : OkHttpClient,
|
||||||
|
private val context : Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Persistence ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
WalletConstants.ALIAS_PREFS_NAME, Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
// ── In-memory alias cache ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private data class CachedAlias(val alias: String, val fetchedAt: Long)
|
||||||
|
|
||||||
|
// Type token for Gson deserialisation
|
||||||
|
private val cacheType = object : TypeToken<Map<String, CachedAlias>>() {}.type
|
||||||
|
|
||||||
|
private val cache = ConcurrentHashMap<String, CachedAlias>()
|
||||||
|
|
||||||
|
// ── Shared alias StateFlow — all screens collect this ─────────────────────
|
||||||
|
|
||||||
|
private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow()
|
||||||
|
|
||||||
|
// ── Init — load persisted cache from SharedPreferences ────────────────────
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPersistedCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPersistedCache() {
|
||||||
|
val json = prefs.getString(WalletConstants.ALIAS_PREFS_KEY, null) ?: run {
|
||||||
|
Log.d(TAG, "ALIAS [PERSIST ] No persisted cache found — starting fresh")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val persisted: Map<String, CachedAlias> = gson.fromJson(json, cacheType)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var loaded = 0
|
||||||
|
var dropped = 0
|
||||||
|
|
||||||
|
persisted.forEach { (pubkey, entry) ->
|
||||||
|
val ageMs = now - entry.fetchedAt
|
||||||
|
if (ageMs < WalletConstants.ALIAS_PERSIST_TTL_MS) {
|
||||||
|
cache[pubkey] = entry
|
||||||
|
loaded++
|
||||||
|
} else {
|
||||||
|
dropped++
|
||||||
|
Log.d(TAG, "ALIAS [PERSIST ] Dropped stale entry for $pubkey (age ${ageMs / 3_600_000}h)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the StateFlow with everything we loaded
|
||||||
|
_aliases.value = cache.mapValues { it.value.alias }
|
||||||
|
|
||||||
|
Log.d(TAG, "ALIAS [PERSIST ] Loaded $loaded entries, dropped $dropped stale entries")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "ALIAS [PERSIST ] Failed to deserialise cache — starting fresh: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistCache() {
|
||||||
|
runCatching {
|
||||||
|
val json = gson.toJson(cache)
|
||||||
|
prefs.edit().putString(WalletConstants.ALIAS_PREFS_KEY, json).apply()
|
||||||
|
Log.d(TAG, "ALIAS [PERSIST ] Wrote ${cache.size} entries to SharedPreferences")
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "ALIAS [PERSIST ] Failed to write cache: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun resolve(pubkey: String): String? {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
cache[pubkey]?.let { entry ->
|
||||||
|
if (now - entry.fetchedAt < WalletConstants.ALIAS_CACHE_TTL_MS) {
|
||||||
|
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey → \"${entry.alias}\" (age ${(now - entry.fetchedAt) / 1000}s)")
|
||||||
|
return entry.alias
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${(now - entry.fetchedAt) / 1000}s) — refetching")
|
||||||
|
}
|
||||||
|
} ?: Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey — fetching from network")
|
||||||
|
|
||||||
|
val alias = fetchFromNetwork(pubkey)
|
||||||
|
|
||||||
|
if (alias == null) {
|
||||||
|
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey — no alias from mempool or 1ml")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-memory cache
|
||||||
|
cache[pubkey] = CachedAlias(alias = alias, fetchedAt = System.currentTimeMillis())
|
||||||
|
|
||||||
|
// Publish to shared StateFlow
|
||||||
|
_aliases.value = _aliases.value + (pubkey to alias)
|
||||||
|
|
||||||
|
// Persist to SharedPreferences
|
||||||
|
persistCache()
|
||||||
|
|
||||||
|
Log.d(TAG, "ALIAS [API FETCHED] $pubkey → \"$alias\"")
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears in-memory cache, StateFlow, and persisted SharedPreferences entry. */
|
||||||
|
fun clearPersistedCache() {
|
||||||
|
cache.clear()
|
||||||
|
_aliases.value = emptyMap()
|
||||||
|
prefs.edit().remove(WalletConstants.ALIAS_PREFS_KEY).apply()
|
||||||
|
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun fetchFromNetwork(pubkey: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
Log.d(TAG, "ALIAS [TRYING ] mempool.space for $pubkey")
|
||||||
|
val mempoolResult = fetchFromMempool(pubkey)
|
||||||
|
if (mempoolResult != null) {
|
||||||
|
Log.d(TAG, "ALIAS [SOURCE ] mempool.space resolved $pubkey → \"$mempoolResult\"")
|
||||||
|
return@withContext mempoolResult
|
||||||
|
}
|
||||||
|
Log.d(TAG, "ALIAS [TRYING ] 1ml.com fallback for $pubkey")
|
||||||
|
val mlResult = fetchFrom1ml(pubkey)
|
||||||
|
if (mlResult != null) {
|
||||||
|
Log.d(TAG, "ALIAS [SOURCE ] 1ml.com resolved $pubkey → \"$mlResult\"")
|
||||||
|
}
|
||||||
|
mlResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchFromMempool(pubkey: String): String? {
|
||||||
|
return try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://mempool.space/api/v1/lightning/nodes/$pubkey")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return null
|
||||||
|
val body = response.body?.string() ?: return null
|
||||||
|
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "mempool.space lookup failed for $pubkey: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchFrom1ml(pubkey: String): String? {
|
||||||
|
return try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://1ml.com/node/$pubkey/json")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return null
|
||||||
|
val body = response.body?.string() ?: return null
|
||||||
|
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "1ml.com lookup failed for $pubkey: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.example.gudariwallet.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import com.example.gudariwallet.api.GsonProvider
|
||||||
|
import com.example.gudariwallet.api.LNbitsClient
|
||||||
|
import com.example.gudariwallet.api.WsPaymentMessage
|
||||||
|
import com.example.gudariwallet.security.EncryptedSecretStore
|
||||||
|
import com.example.gudariwallet.util.NotificationConstants
|
||||||
|
import com.example.gudariwallet.util.NotificationHelper
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
|
||||||
|
private const val TAG = "WalletNotifService"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that owns the single wallet-level WebSocket connection.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Keeps one WebSocket open to wss://{baseUrl}/api/v1/ws/{invoiceKey}
|
||||||
|
* - Posts user-facing notifications for all payment events (in-app or external)
|
||||||
|
* - Emits payment events to [paymentEvents] SharedFlow so the ViewModel can
|
||||||
|
* update the UI when the app is foregrounded — zero extra network cost
|
||||||
|
* - Reconnects with exponential backoff on failure
|
||||||
|
* - Survives process kill via START_REDELIVER_INTENT
|
||||||
|
*
|
||||||
|
* Lifetime: starts once after onboarding completes, stops only on logout.
|
||||||
|
* Never started or stopped by invoice creation.
|
||||||
|
*/
|
||||||
|
class WalletNotificationService : Service() {
|
||||||
|
|
||||||
|
// ── Companion: shared state accessible by ViewModel ───────────────────────
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment events emitted by the service.
|
||||||
|
* The ViewModel collects this flow to update UI state when the app is
|
||||||
|
* in the foreground. replay=0 means no stale events are delivered to
|
||||||
|
* late collectors — a payment that arrived while the app was closed
|
||||||
|
* is communicated via the notification, not via this flow.
|
||||||
|
*/
|
||||||
|
private val _paymentEvents = MutableSharedFlow<PaymentEvent>(replay = 0)
|
||||||
|
val paymentEvents: SharedFlow<PaymentEvent> = _paymentEvents.asSharedFlow()
|
||||||
|
|
||||||
|
/** Convenience wrapper — use instead of constructing the Intent manually. */
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startForegroundService(Intent(context, WalletNotificationService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience wrapper — stops the service and closes the WebSocket. */
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, WalletNotificationService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a payment event received from the wallet-level WebSocket.
|
||||||
|
* Emitted to [paymentEvents] for in-process UI updates.
|
||||||
|
*/
|
||||||
|
data class PaymentEvent(
|
||||||
|
val amountSats: Long,
|
||||||
|
val memo: String?,
|
||||||
|
val isOutgoing: Boolean,
|
||||||
|
val paymentHash: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Service internals ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SupervisorJob ensures one failing coroutine doesn't cancel the others.
|
||||||
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private var reconnectAttempts = 0
|
||||||
|
private var currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
private val gson = GsonProvider.gson
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Step 1: Call startForeground immediately — Android requires this within
|
||||||
|
// 5 seconds of startForegroundService() or the app is ANR'd.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NotificationConstants.NOTIF_ID_SERVICE,
|
||||||
|
NotificationHelper.buildServiceNotification(this),
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(
|
||||||
|
NotificationConstants.NOTIF_ID_SERVICE,
|
||||||
|
NotificationHelper.buildServiceNotification(this)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Open the WebSocket. If already open (e.g. service restarted
|
||||||
|
// by Android after a kill), close the old one first.
|
||||||
|
openWebSocket()
|
||||||
|
|
||||||
|
// START_REDELIVER_INTENT: if Android kills the service under memory
|
||||||
|
// pressure, it will restart it and re-deliver the last intent.
|
||||||
|
// This ensures the service always has credentials to reconnect.
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
Log.d(TAG, "Service destroyed — closing WebSocket")
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
webSocket?.close(WalletConstants.WS_CLOSE_NORMAL, "Service stopped")
|
||||||
|
webSocket = null
|
||||||
|
serviceScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// onBind returns null — this is a started service, not a bound service.
|
||||||
|
// Communication with the ViewModel happens via the companion SharedFlow.
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun openWebSocket() {
|
||||||
|
val secrets = EncryptedSecretStore(applicationContext)
|
||||||
|
|
||||||
|
if (secrets.invoiceKey.isBlank() || secrets.baseUrl.isBlank()) {
|
||||||
|
Log.e(TAG, "Credentials not available — cannot open WebSocket")
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val wsUrl = secrets.baseUrl
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://")
|
||||||
|
.trimEnd('/') + "/api/v1/ws/${secrets.invoiceKey}"
|
||||||
|
|
||||||
|
Log.d(TAG, "Opening WebSocket: $wsUrl")
|
||||||
|
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
|
||||||
|
webSocket = LNbitsClient.httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
Log.d(TAG, "WebSocket connected")
|
||||||
|
// Reset backoff on successful connection
|
||||||
|
reconnectAttempts = 0
|
||||||
|
currentBackoffMs = WalletConstants.WS_INITIAL_BACKOFF_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
Log.d(TAG, "WebSocket message: $text")
|
||||||
|
handleMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Log.e(TAG, "WebSocket failure (attempt $reconnectAttempts): ${t.message}")
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.d(TAG, "WebSocket closing: $code $reason")
|
||||||
|
webSocket.close(WalletConstants.WS_CLOSE_NORMAL, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.d(TAG, "WebSocket closed: $code $reason")
|
||||||
|
// Only reconnect on unexpected closes (not our own WS_CLOSE_NORMAL)
|
||||||
|
if (code != WalletConstants.WS_CLOSE_NORMAL) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMessage(text: String) {
|
||||||
|
val msg = runCatching {
|
||||||
|
gson.fromJson(text, WsPaymentMessage::class.java)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
|
||||||
|
val payment = msg.payment ?: return
|
||||||
|
|
||||||
|
// Only act on successful payments — ignore pending/failed events
|
||||||
|
if (payment.status != "success") return
|
||||||
|
|
||||||
|
val amountSats = kotlin.math.abs(payment.amount) / WalletConstants.MSAT_PER_SAT
|
||||||
|
val isOutgoing = payment.amount < 0
|
||||||
|
|
||||||
|
// Post notification — works whether app is foreground or background
|
||||||
|
if (isOutgoing) {
|
||||||
|
NotificationHelper.notifyPaymentSent(
|
||||||
|
context = applicationContext,
|
||||||
|
amountSats = amountSats,
|
||||||
|
paymentHash = payment.paymentHash
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
NotificationHelper.notifyPaymentReceived(
|
||||||
|
context = applicationContext,
|
||||||
|
amountSats = amountSats,
|
||||||
|
memo = payment.memo,
|
||||||
|
paymentHash = payment.paymentHash
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit to SharedFlow — ViewModel collects this to update UI in real time
|
||||||
|
// when the app is foregrounded. Fire-and-forget; no suspension needed.
|
||||||
|
serviceScope.launch {
|
||||||
|
_paymentEvents.emit(
|
||||||
|
PaymentEvent(
|
||||||
|
amountSats = amountSats,
|
||||||
|
memo = payment.memo,
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
paymentHash = payment.paymentHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (reconnectAttempts >= WalletConstants.WS_MAX_ATTEMPTS) {
|
||||||
|
Log.e(TAG, "WebSocket gave up after ${WalletConstants.WS_MAX_ATTEMPTS} attempts — service will wait for next start")
|
||||||
|
// Do not stop the service — it will reconnect on next onStartCommand
|
||||||
|
// (e.g. when the app is brought to foreground and MainActivity calls start())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val backoff = currentBackoffMs
|
||||||
|
reconnectAttempts++
|
||||||
|
currentBackoffMs = (currentBackoffMs * 2).coerceAtMost(WalletConstants.WS_MAX_BACKOFF_MS)
|
||||||
|
|
||||||
|
Log.d(TAG, "Reconnecting in ${backoff}ms (attempt $reconnectAttempts of ${WalletConstants.WS_MAX_ATTEMPTS})")
|
||||||
|
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = serviceScope.launch {
|
||||||
|
delay(backoff)
|
||||||
|
openWebSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
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.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.platform.LocalContext
|
||||||
|
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 androidx.core.net.toUri
|
||||||
|
import com.example.gudariwallet.api.PaymentRecord
|
||||||
|
import com.example.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.example.gudariwallet.util.feePpm
|
||||||
|
import com.example.gudariwallet.util.formatFiatForSats // ← new import
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── List screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
vm: HistoryViewModel,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onPaymentClick: (PaymentRecord) -> Unit
|
||||||
|
) {
|
||||||
|
val state by vm.state.collectAsState()
|
||||||
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("History") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { vm.refresh() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
|
||||||
|
is HistoryState.Loading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
|
||||||
|
is HistoryState.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = s.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = { vm.refresh() }) { Text("Retry") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is HistoryState.Success -> {
|
||||||
|
if (s.payments.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No payments yet.",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
if (s.isRefreshing) {
|
||||||
|
item {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = s.payments,
|
||||||
|
key = { it.paymentHash }
|
||||||
|
) { payment ->
|
||||||
|
PaymentRow(
|
||||||
|
payment = payment,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
onClick = { onPaymentClick(payment) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.canLoadMore) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { vm.loadMore() }) {
|
||||||
|
Text("More")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentRow(
|
||||||
|
payment : PaymentRecord,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
fiatSatsPerUnit : Double?,
|
||||||
|
onClick : () -> Unit
|
||||||
|
) {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
// ── Pending detection ────────────────────────────────────────────────────
|
||||||
|
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||||
|
|
||||||
|
// bitcoin.design: green for received, neutral for sent, muted for pending.
|
||||||
|
val amountColor = when {
|
||||||
|
isPending -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> semantic.success
|
||||||
|
}
|
||||||
|
|
||||||
|
val amountPrefix = if (payment.isOutgoing) "−" else "+"
|
||||||
|
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
|
else Icons.Default.ArrowDownward
|
||||||
|
|
||||||
|
// Fiat secondary line — only shown when rate is available
|
||||||
|
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||||
|
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Direction icon — tinted to match amount color
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
|
||||||
|
tint = amountColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Left: memo + timestamp + optional pending badge
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = formatTimestamp(payment.time),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
// ── Pending badge ────────────────────────────────────────────────
|
||||||
|
if (isPending) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
color = semantic.warningContainer,
|
||||||
|
contentColor = semantic.onWarningContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Pending",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Right: sats (primary) + fiat (secondary) — right-aligned
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "$amountPrefix${payment.amountSat} sats",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
color = amountColor
|
||||||
|
)
|
||||||
|
if (fiatLine != null) {
|
||||||
|
Spacer(Modifier.height(1.dp))
|
||||||
|
Text(
|
||||||
|
text = fiatLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Detail screen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PaymentDetailScreen(
|
||||||
|
payment: PaymentRecord,
|
||||||
|
vm: HistoryViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val detailState by vm.detailState.collectAsState()
|
||||||
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
||||||
|
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||||
|
|
||||||
|
val enriched: PaymentRecord = when (val s = detailState) {
|
||||||
|
is DetailState.Success -> {
|
||||||
|
val detail = s.detail.details
|
||||||
|
when {
|
||||||
|
detail == null -> payment
|
||||||
|
else -> detail.copy(
|
||||||
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||||
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DetailState.Partial -> s.record
|
||||||
|
else -> payment
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when (detailState) {
|
||||||
|
is DetailState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is DetailState.Error -> {
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Could not load full details",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PaymentDetailContent(
|
||||||
|
payment = enriched,
|
||||||
|
vm = vm,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
PaymentDetailContent(
|
||||||
|
payment = enriched,
|
||||||
|
vm = vm,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentDetailContent(
|
||||||
|
payment : PaymentRecord,
|
||||||
|
vm : HistoryViewModel,
|
||||||
|
fiatCurrency : String?, // ← new
|
||||||
|
fiatSatsPerUnit : Double?, // ← new
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isPending = payment.status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||||
|
val semantic = semanticColors()
|
||||||
|
val amountColor = when {
|
||||||
|
isPending -> MaterialTheme.colorScheme.onSurfaceVariant // muted
|
||||||
|
payment.isOutgoing -> MaterialTheme.colorScheme.onSurface // neutral
|
||||||
|
else -> semantic.success // green
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val destinationMap by vm.destinationState.collectAsState()
|
||||||
|
val bolt11 = payment.bolt11?.takeIf { it.isNotBlank() && payment.isOutgoing }
|
||||||
|
LaunchedEffect(payment.paymentHash) {
|
||||||
|
if (bolt11 != null) vm.resolveDestination(bolt11, payment.paymentHash)
|
||||||
|
}
|
||||||
|
val pubkey = destinationMap[payment.paymentHash]
|
||||||
|
val aliases by vm.nodeAliases.collectAsState()
|
||||||
|
|
||||||
|
// Pre-compute fiat strings for hero + fee
|
||||||
|
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||||
|
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
val feeFiat: String? = remember(payment.feeSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.feeSat > 0)
|
||||||
|
formatFiatForSats(payment.feeSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Hero amount ──────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
|
else Icons.Default.ArrowDownward,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = amountColor,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Primary: sats
|
||||||
|
Text(
|
||||||
|
text = "${if (payment.isOutgoing) "−" else "+"}${payment.amountSat} sats",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
color = amountColor
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secondary: fiat equivalent
|
||||||
|
if (heroFiat != null) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "≈ $heroFiat",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee line (with fiat if available)
|
||||||
|
if (payment.feeSat > 0) {
|
||||||
|
val ppm = feePpm(
|
||||||
|
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||||
|
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||||
|
)
|
||||||
|
val feeLabel = buildString {
|
||||||
|
append("Fee: ${payment.feeSat} sats")
|
||||||
|
if (feeFiat != null) append(" (≈ $feeFiat)")
|
||||||
|
if (ppm != null) append(" · $ppm ppm")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = feeLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
StatusChip(status = payment.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic info ───────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Details") {
|
||||||
|
DetailRow("Date", formatTimestamp(payment.time))
|
||||||
|
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "—")
|
||||||
|
if (payment.extra?.tag != null) {
|
||||||
|
DetailRow("Type", payment.extra.tag.uppercase())
|
||||||
|
}
|
||||||
|
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||||
|
DetailRow("Comment", payment.extra.comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success action (LNURL) ───────────────────────────────────────────
|
||||||
|
payment.extra?.successAction?.let { action ->
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Success Action") {
|
||||||
|
action.message?.let { DetailRow("Message", it) }
|
||||||
|
action.url?.let { DetailRow("URL", it) }
|
||||||
|
action.description?.let { DetailRow("Description", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Technical ────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Technical") {
|
||||||
|
DetailRow(
|
||||||
|
label = "Payment Hash",
|
||||||
|
value = payment.paymentHash,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))) } }
|
||||||
|
)
|
||||||
|
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||||
|
DetailRow(
|
||||||
|
label = "Preimage",
|
||||||
|
value = payment.preimage,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pubkey != null) {
|
||||||
|
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||||
|
val label = aliases[pubkey] ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||||
|
DetailRow(
|
||||||
|
label = "Destination",
|
||||||
|
value = label,
|
||||||
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
onValueClick = {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("node_id", pubkey))) } },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy node ID",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOLT11 invoice ───────────────────────────────────────────────────
|
||||||
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Invoice") {
|
||||||
|
DetailRow(
|
||||||
|
label = "BOLT11",
|
||||||
|
value = payment.bolt11,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
maxLines = 3,
|
||||||
|
onCopy = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reusable section wrapper ─────────────────────────────────────────────────
|
||||||
|
// (unchanged — kept for completeness)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Single label/value row ───────────────────────────────────────────────────
|
||||||
|
// (unchanged)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status chip ──────────────────────────────────────────────────────────────
|
||||||
|
// (unchanged)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusChip(status: String) {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
val (containerColor, contentColor) = when (status.lowercase()) {
|
||||||
|
"success", "complete", "paid" ->
|
||||||
|
semantic.successContainer to semantic.onSuccessContainer
|
||||||
|
|
||||||
|
"pending", "in_flight", "inflight" ->
|
||||||
|
semantic.warningContainer to semantic.onWarningContainer
|
||||||
|
|
||||||
|
"failed", "error", "expired" ->
|
||||||
|
semantic.errorContainer to semantic.onErrorContainer
|
||||||
|
|
||||||
|
else ->
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = status.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun formatTimestamp(time: String): String {
|
||||||
|
return runCatching {
|
||||||
|
val epochSeconds = time.toLong()
|
||||||
|
java.time.format.DateTimeFormatter
|
||||||
|
.ofPattern("dd MMM yyyy, HH:mm")
|
||||||
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
.format(java.time.Instant.ofEpochSecond(epochSeconds))
|
||||||
|
}.recoverCatching {
|
||||||
|
val odt = java.time.OffsetDateTime.parse(time)
|
||||||
|
java.time.format.DateTimeFormatter
|
||||||
|
.ofPattern("dd MMM yyyy, HH:mm")
|
||||||
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
.format(odt)
|
||||||
|
}.getOrDefault(time)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.gudariwallet.data.NodeAliasRepository
|
||||||
|
import com.example.gudariwallet.api.PaymentRecord
|
||||||
|
import com.example.gudariwallet.data.PaymentCacheRepository
|
||||||
|
import com.example.gudariwallet.data.WalletRepository
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class HistoryViewModel(
|
||||||
|
private val repo : WalletRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
val fiatCurrency : StateFlow<String?>, // passed from WalletViewModel.selectedCurrency
|
||||||
|
val fiatSatsPerUnit : StateFlow<Double?> // passed from WalletViewModel.fiatSatsPerUnit
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<HistoryState>(HistoryState.Loading)
|
||||||
|
val state: StateFlow<HistoryState> = _state
|
||||||
|
|
||||||
|
private var currentOffset = 0
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Cache read moved to IO dispatcher — never blocks the main thread,
|
||||||
|
// so navigation to HistoryScreen is instant on tap.
|
||||||
|
viewModelScope.launch {
|
||||||
|
val cached = withContext(Dispatchers.IO) {
|
||||||
|
paymentCache.loadCachedPayments()
|
||||||
|
}
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [INIT ] Showing ${cached.size} cached payments immediately")
|
||||||
|
_state.value = HistoryState.Success(
|
||||||
|
payments = cached,
|
||||||
|
canLoadMore = cached.size >= WalletConstants.PAYMENTS_PAGE_SIZE,
|
||||||
|
isRefreshing = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [INIT ] No cache — cold load")
|
||||||
|
}
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
// Note: no loadFiatRate() here — fiatSatsPerUnit is owned by WalletViewModel
|
||||||
|
// and already kept up to date. Zero extra network calls.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payments list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
loadJob?.cancel()
|
||||||
|
currentOffset = 0
|
||||||
|
val current = _state.value
|
||||||
|
if (current is HistoryState.Success) {
|
||||||
|
_state.value = current.copy(isRefreshing = true)
|
||||||
|
} else {
|
||||||
|
_state.value = HistoryState.Loading
|
||||||
|
}
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
if (loadJob?.isActive == true) return
|
||||||
|
val current = _state.value
|
||||||
|
if (current is HistoryState.Success && !current.canLoadMore) return
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPage() {
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repo.getPayments(
|
||||||
|
offset = currentOffset,
|
||||||
|
limit = WalletConstants.PAYMENTS_PAGE_SIZE
|
||||||
|
)
|
||||||
|
}.onSuccess { newPage ->
|
||||||
|
val canLoadMore = newPage.size >= WalletConstants.PAYMENTS_PAGE_SIZE
|
||||||
|
val existing = (_state.value as? HistoryState.Success)?.payments ?: emptyList()
|
||||||
|
val combined = if (currentOffset == 0) newPage else existing + newPage
|
||||||
|
val deduplicated = combined.distinctBy { it.paymentHash }
|
||||||
|
currentOffset += newPage.size
|
||||||
|
|
||||||
|
val previousCount = existing.size
|
||||||
|
val newCount = deduplicated.size
|
||||||
|
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
|
||||||
|
when {
|
||||||
|
previousCount == 0 -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cold load — got ${newPage.size} payments")
|
||||||
|
newCount > previousCount -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] ${newCount - previousCount} new payment(s) since cache")
|
||||||
|
else -> Log.d("HistoryViewModel", "PAYMENTS [NETWORK ] Cache was up to date — no new payments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = HistoryState.Success(
|
||||||
|
payments = deduplicated,
|
||||||
|
canLoadMore = canLoadMore,
|
||||||
|
isRefreshing = false
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentOffset <= WalletConstants.PAYMENTS_PAGE_SIZE) {
|
||||||
|
paymentCache.savePayments(deduplicated)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
val current = _state.value
|
||||||
|
if (current is HistoryState.Success) {
|
||||||
|
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] Keeping cached list — ${e.message}")
|
||||||
|
_state.value = current.copy(isRefreshing = false)
|
||||||
|
} else {
|
||||||
|
Log.w("HistoryViewModel", "PAYMENTS [NET ERROR ] No cache to fall back on — ${e.message}")
|
||||||
|
_state.value = HistoryState.Error(e.message ?: "Failed to load payments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment detail ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val _detailState = MutableStateFlow<DetailState>(DetailState.Idle)
|
||||||
|
val detailState: StateFlow<DetailState> = _detailState
|
||||||
|
|
||||||
|
fun loadDetail(checkingId: String, record: PaymentRecord? = null) {
|
||||||
|
paymentCache.getCachedDetail(checkingId)?.let {
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [DETAIL HIT] $checkingId — instant from cache")
|
||||||
|
_detailState.value = DetailState.Success(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record != null) {
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [DETAIL PAR] $checkingId — showing partial from PaymentRecord")
|
||||||
|
_detailState.value = DetailState.Partial(record)
|
||||||
|
} else {
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [DETAIL LOD] $checkingId — no record, showing spinner")
|
||||||
|
_detailState.value = DetailState.Loading
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repo.getPaymentDetail(checkingId)
|
||||||
|
}.onSuccess { detail ->
|
||||||
|
Log.d("HistoryViewModel", "PAYMENTS [DETAIL NET] $checkingId — fetched from network")
|
||||||
|
paymentCache.saveDetail(checkingId, detail)
|
||||||
|
_detailState.value = DetailState.Success(detail)
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w("HistoryViewModel", "PAYMENTS [DETAIL ERR] $checkingId — ${e.message}")
|
||||||
|
if (_detailState.value !is DetailState.Partial) {
|
||||||
|
_detailState.value = DetailState.Error(e.message ?: "Failed to load payment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDetail() {
|
||||||
|
_detailState.value = DetailState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destination pubkey + alias resolution ─────────────────────────────────
|
||||||
|
|
||||||
|
val nodeAliases: StateFlow<Map<String, String>> = aliasRepo.aliases
|
||||||
|
|
||||||
|
private val _destinationState = MutableStateFlow<Map<String, String?>>(emptyMap())
|
||||||
|
val destinationState: StateFlow<Map<String, String?>> = _destinationState
|
||||||
|
|
||||||
|
fun resolveDestination(bolt11: String, paymentHash: String) {
|
||||||
|
if (_destinationState.value.containsKey(paymentHash)) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val pubkey = runCatching { repo.decodeBolt11(bolt11) }
|
||||||
|
.getOrNull()
|
||||||
|
?.payee
|
||||||
|
_destinationState.update { it + (paymentHash to pubkey) }
|
||||||
|
if (pubkey != null) aliasRepo.resolve(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class HistoryViewModelFactory(
|
||||||
|
private val repo : WalletRepository,
|
||||||
|
private val aliasRepo : NodeAliasRepository,
|
||||||
|
private val paymentCache : PaymentCacheRepository,
|
||||||
|
private val fiatCurrency : StateFlow<String?>,
|
||||||
|
private val fiatSatsPerUnit: StateFlow<Double?>
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return HistoryViewModel(
|
||||||
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
|
paymentCache = paymentCache,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
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.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import com.example.gudariwallet.util.formatFiat
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeTab(
|
||||||
|
vm: WalletViewModel,
|
||||||
|
onHistoryClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val balanceState by vm.balanceState.collectAsState()
|
||||||
|
|
||||||
|
val balanceHidden by vm.balanceHidden.collectAsState()
|
||||||
|
|
||||||
|
var eyeVisible by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(balanceHidden) {
|
||||||
|
eyeVisible = true
|
||||||
|
delay(1_500)
|
||||||
|
eyeVisible = false
|
||||||
|
}
|
||||||
|
val eyeAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (eyeVisible) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 400),
|
||||||
|
label = "eyeAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
val isRefreshing = balanceState is BalanceState.Loading
|
||||||
|
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = { vm.refreshBalance() },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
when (val s = balanceState) {
|
||||||
|
|
||||||
|
is BalanceState.Loading -> {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Loading balance…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BalanceState.Success -> {
|
||||||
|
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||||
|
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||||
|
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||||
|
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
LaunchedEffect(showCurrencyPicker) {
|
||||||
|
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||||
|
isLoadingCurrencies = true
|
||||||
|
currencyList = vm.getSupportedCurrencies()
|
||||||
|
isLoadingCurrencies = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (balanceHidden)
|
||||||
|
Icons.Filled.VisibilityOff
|
||||||
|
else
|
||||||
|
Icons.Filled.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.alpha(eyeAlpha)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (balanceHidden) "••••••" else "${s.sats}",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = if (balanceHidden)
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.clickable(
|
||||||
|
onClick = { vm.toggleBalanceVisibility() },
|
||||||
|
onClickLabel = if (balanceHidden) "Reveal balance" else "Hide balance",
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Spacer(Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "sats",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
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,
|
||||||
|
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(2.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = { showCurrencyPicker = true },
|
||||||
|
onClickLabel = "Change currency",
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
|
.padding(start = 0.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedCurrency ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (selectedCurrency != null)
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
// Triangle only shown when null — signals "tap to set a currency"
|
||||||
|
if (selectedCurrency == null) {
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowDropDown,
|
||||||
|
contentDescription = "Change currency",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (!s.isRefreshing) {
|
||||||
|
val updatedLabel: String = remember(s.lastUpdated) {
|
||||||
|
formatLastUpdated(s.lastUpdated)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Updated $updatedLabel",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = onHistoryClick) {
|
||||||
|
Text(
|
||||||
|
text = "View History",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showCurrencyPicker) {
|
||||||
|
CurrencyPickerSheet(
|
||||||
|
currencies = currencyList,
|
||||||
|
selectedCurrency = selectedCurrency,
|
||||||
|
onSelect = { code ->
|
||||||
|
vm.setSelectedCurrency(code)
|
||||||
|
showCurrencyPicker = false
|
||||||
|
},
|
||||||
|
isLoading = isLoadingCurrencies,
|
||||||
|
onDismiss = { showCurrencyPicker = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is BalanceState.Error -> {
|
||||||
|
if (s.staleSats != null) {
|
||||||
|
Text(
|
||||||
|
text = if (balanceHidden) "••••••" else "${s.staleSats}",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "sats",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.85f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (s.staleSats != null)
|
||||||
|
"Could not refresh — pull down to try again"
|
||||||
|
else
|
||||||
|
"Could not load balance — pull down to try again",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLastUpdated(epochMs: Long): String {
|
||||||
|
val ageMs = System.currentTimeMillis() - epochMs
|
||||||
|
return when {
|
||||||
|
ageMs < 60_000L -> "just now"
|
||||||
|
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
|
||||||
|
else -> {
|
||||||
|
val formatter = java.time.format.DateTimeFormatter
|
||||||
|
.ofPattern("HH:mm")
|
||||||
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun CurrencyPickerSheet(
|
||||||
|
currencies : List<String>,
|
||||||
|
isLoading : Boolean, // NEW — true while list is being fetched
|
||||||
|
selectedCurrency: String?,
|
||||||
|
onSelect : (String?) -> Unit,
|
||||||
|
onDismiss : () -> Unit
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
// Filter list client-side — no network, instant response
|
||||||
|
// Only active once the list is populated
|
||||||
|
val filtered = remember(query, currencies) {
|
||||||
|
if (query.isBlank()) currencies
|
||||||
|
else currencies.filter { it.contains(query.trim(), ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Select currency",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── "None" option — always pinned at top, never filtered ──────────
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "None",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (selectedCurrency == null)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = "Hide fiat equivalent",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = if (selectedCurrency == null) ({
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}) else null,
|
||||||
|
modifier = Modifier.clickable { onSelect(null) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
|
||||||
|
// ── Search field — hidden while loading ───────────────────────────
|
||||||
|
if (!isLoading) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
placeholder = { Text("Search…") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Search
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onSearch = { focusManager.clearFocus() }
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body — spinner OR list OR empty state ─────────────────────────
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
// Spinner while list is being fetched on first open
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.isEmpty() -> {
|
||||||
|
// No results after search
|
||||||
|
Text(
|
||||||
|
text = if (query.isBlank()) "No currencies available"
|
||||||
|
else "No currencies match \"$query\"",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
items(filtered, key = { it }) { code ->
|
||||||
|
val isSelected = code == selectedCurrency
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = code,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (isSelected)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = if (isSelected) ({
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}) else null,
|
||||||
|
modifier = Modifier.clickable { onSelect(code) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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
|
||||||
|
|
||||||
|
// ─── Permission state model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed class NotificationPermissionState {
|
||||||
|
data object NeverAsked : NotificationPermissionState()
|
||||||
|
data object Rationale : NotificationPermissionState() // asked once, denied
|
||||||
|
data object PermanentlyDenied: NotificationPermissionState() // "don't ask again"
|
||||||
|
data object Granted : NotificationPermissionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prefs helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a flag so we can distinguish between:
|
||||||
|
* - "never asked" → show the first-time request screen
|
||||||
|
* - "asked and denied" → show rationale
|
||||||
|
* - "permanently denied"→ send user to system settings
|
||||||
|
*/
|
||||||
|
object PermissionPrefs {
|
||||||
|
private const val PREFS_NAME = "lnbits_prefs"
|
||||||
|
private const val KEY = "notification_permission_requested"
|
||||||
|
|
||||||
|
fun hasRequestedBefore(context: Context): Boolean =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(KEY, false)
|
||||||
|
|
||||||
|
fun markRequested(context: Context) =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(KEY, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main composable ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the full POST_NOTIFICATIONS runtime permission flow.
|
||||||
|
*
|
||||||
|
* On Android 12 and below this permission doesn't exist — we skip straight
|
||||||
|
* to onPermissionGranted since notifications are always allowed.
|
||||||
|
*
|
||||||
|
* @param hasRequestedBefore Read from PermissionPrefs — distinguishes
|
||||||
|
* "never asked" from "permanently denied"
|
||||||
|
* @param onPermissionGranted Called when permission is granted
|
||||||
|
* @param onPermissionDenied Called when user skips or permanently denies
|
||||||
|
* @param onRequestedFirstTime Called after the first system prompt fires —
|
||||||
|
* persists the flag via PermissionPrefs
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationPermissionScreen(
|
||||||
|
hasRequestedBefore: Boolean,
|
||||||
|
onPermissionGranted: () -> Unit,
|
||||||
|
onPermissionDenied: () -> Unit,
|
||||||
|
onRequestedFirstTime: () -> Unit
|
||||||
|
) {
|
||||||
|
// Android 12 and below — POST_NOTIFICATIONS doesn't exist, skip entirely
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
LaunchedEffect(Unit) { onPermissionGranted() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// Tracks whether the system already granted the permission
|
||||||
|
// (e.g. user granted it in a previous session)
|
||||||
|
var permissionState by remember {
|
||||||
|
mutableStateOf<NotificationPermissionState>(
|
||||||
|
when {
|
||||||
|
!hasRequestedBefore -> NotificationPermissionState.NeverAsked
|
||||||
|
else -> NotificationPermissionState.Rationale
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The system permission launcher
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
permissionState = if (granted) {
|
||||||
|
NotificationPermissionState.Granted
|
||||||
|
} else {
|
||||||
|
// If we've already asked before and they denied again → permanent denial
|
||||||
|
if (hasRequestedBefore) NotificationPermissionState.PermanentlyDenied
|
||||||
|
else NotificationPermissionState.Rationale
|
||||||
|
}
|
||||||
|
onRequestedFirstTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to state changes
|
||||||
|
LaunchedEffect(permissionState) {
|
||||||
|
when (permissionState) {
|
||||||
|
is NotificationPermissionState.Granted -> onPermissionGranted()
|
||||||
|
else -> { /* handled by UI below */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI ────────────────────────────────────────────────────────────────────
|
||||||
|
when (permissionState) {
|
||||||
|
|
||||||
|
is NotificationPermissionState.NeverAsked,
|
||||||
|
is NotificationPermissionState.Rationale -> {
|
||||||
|
PermissionRequestCard(
|
||||||
|
onAllow = {
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
},
|
||||||
|
onSkip = onPermissionDenied
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NotificationPermissionState.PermanentlyDenied -> {
|
||||||
|
PermanentlyDeniedCard(
|
||||||
|
onOpenSettings = {
|
||||||
|
// Send user to the app's system notification settings
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", context.packageName, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSkip = onPermissionDenied
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NotificationPermissionState.Granted -> {
|
||||||
|
// LaunchedEffect above handles this — show nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-composables ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionRequestCard(
|
||||||
|
onAllow: () -> Unit,
|
||||||
|
onSkip: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Notifications,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = "Stay notified of payments",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Allow notifications so you know the moment a Lightning payment arrives, even when the app is in the background.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onAllow,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Allow Notifications")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Not Now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermanentlyDeniedCard(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onSkip: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.NotificationsOff,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = "Notifications are blocked",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "You've permanently denied notifications. To receive payment alerts, enable them in system settings.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Open Settings")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Skip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.*
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.gudariwallet.security.SecretStore
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreen(
|
||||||
|
secretStore: SecretStore,
|
||||||
|
onComplete: () -> Unit
|
||||||
|
) {
|
||||||
|
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
|
||||||
|
var invoiceKey by remember { mutableStateOf("") }
|
||||||
|
var adminKey by remember { mutableStateOf("") }
|
||||||
|
var adminVisible by remember { mutableStateOf(false) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text("Connect to LNbits", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Enter your LNbits server URL and API keys.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { baseUrl = it },
|
||||||
|
label = { Text("Server URL") },
|
||||||
|
placeholder = { Text("https://bitcointxoko.org") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = invoiceKey,
|
||||||
|
onValueChange = { invoiceKey = it },
|
||||||
|
label = { Text("Invoice Key (read-only)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
visualTransformation = PasswordVisualTransformation()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = adminKey,
|
||||||
|
onValueChange = { adminKey = it },
|
||||||
|
label = { Text("Admin Key (for sending)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
visualTransformation = if (adminVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { adminVisible = !adminVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (adminVisible) Icons.Default.VisibilityOff
|
||||||
|
else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (adminVisible) "Hide" else "Show"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) {
|
||||||
|
error = "All fields are required."
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
|
||||||
|
onComplete()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when an invoice has been paid.
|
||||||
|
* Displays a pulsing checkmark, the amount received, and the memo.
|
||||||
|
*
|
||||||
|
* @param amountSats Amount received in satoshis
|
||||||
|
* @param memo Invoice memo
|
||||||
|
* @param onDone Called when the user taps "Done" — navigates back to Home
|
||||||
|
* @param onReceiveMore Called when the user taps "Receive More" — resets the screen
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PaymentSuccessScreen(
|
||||||
|
amountSats: Long,
|
||||||
|
memo: String,
|
||||||
|
onDone: () -> Unit,
|
||||||
|
onReceiveMore: () -> Unit
|
||||||
|
) {
|
||||||
|
// Pulsing scale animation on the checkmark icon
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
|
val scale by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 1.08f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 700, easing = EaseInOut),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "checkScale"
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// ── Animated checkmark ────────────────────────────────────────────
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CheckCircle,
|
||||||
|
contentDescription = "Payment received",
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary, // green role
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.scale(scale)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// ── Title ─────────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Payment Received!",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ── Amount ────────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "+$amountSats sats",
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Memo ──────────────────────────────────────────────────────────
|
||||||
|
if (memo.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = memo,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(48.dp))
|
||||||
|
|
||||||
|
// ── Actions ───────────────────────────────────────────────────────
|
||||||
|
Button(
|
||||||
|
onClick = onDone,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onReceiveMore,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Receive More")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches the zxing-android-embedded scanner activity and calls [onScanned]
|
||||||
|
* with the raw result string, or [onDismiss] if the user cancels.
|
||||||
|
*
|
||||||
|
* Camera permission is handled internally by the scanner activity — no
|
||||||
|
* Accompanist boilerplate needed here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QrScannerScreen(
|
||||||
|
onScanned: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||||
|
val content = result.contents
|
||||||
|
if (content != null) {
|
||||||
|
onScanned(content)
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch the scanner as soon as this composable enters the composition.
|
||||||
|
// The scanner activity takes over the screen; when it finishes the result
|
||||||
|
// callback above fires and we navigate away.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val options = ScanOptions().apply {
|
||||||
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
setPrompt("") // no bottom prompt text
|
||||||
|
setBeepEnabled(false) // wallet apps are usually silent
|
||||||
|
setOrientationLocked(true)
|
||||||
|
}
|
||||||
|
launcher.launch(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.gudariwallet.ui.components.UnitWheelPicker
|
||||||
|
import com.example.gudariwallet.ui.receive.AmountDisplay
|
||||||
|
import com.example.gudariwallet.ui.receive.QrDisplayCard
|
||||||
|
import com.example.gudariwallet.util.fiatLabel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReceiveScreen(vm: WalletViewModel) {
|
||||||
|
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
||||||
|
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
|
||||||
|
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
|
||||||
|
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val window = (context as Activity).window
|
||||||
|
val originalBrightness = remember { window.attributes.screenBrightness }
|
||||||
|
var brightnessOn by remember { mutableStateOf(false) }
|
||||||
|
var showAddress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val onToggleBrightness = {
|
||||||
|
val lp = window.attributes
|
||||||
|
lp.screenBrightness = if (!brightnessOn)
|
||||||
|
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||||
|
else originalBrightness
|
||||||
|
window.attributes = lp
|
||||||
|
brightnessOn = !brightnessOn
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
val lp = window.attributes
|
||||||
|
lp.screenBrightness = originalBrightness
|
||||||
|
window.attributes = lp
|
||||||
|
vm.resetReceiveState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (receiveState !is ReceiveState.InvoiceReady) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
when (val state = receiveState) {
|
||||||
|
|
||||||
|
is ReceiveState.Idle,
|
||||||
|
is ReceiveState.AwaitingInvoice -> {
|
||||||
|
ReceiveIdleContent(
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
lightningAddress = lightningAddress,
|
||||||
|
showAddress = showAddress,
|
||||||
|
onToggleAddress = { showAddress = !showAddress },
|
||||||
|
brightnessOn = brightnessOn,
|
||||||
|
onToggleBrightness = onToggleBrightness,
|
||||||
|
isLoading = receiveState is ReceiveState.AwaitingInvoice,
|
||||||
|
onCreateInvoice = { amountSats, memo ->
|
||||||
|
vm.createInvoice(amountSats, memo)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.LnurlWithdrawReady -> {
|
||||||
|
LnurlWithdrawSheet(
|
||||||
|
state = state,
|
||||||
|
onConfirm = { amountSats ->
|
||||||
|
vm.executeWithdraw(
|
||||||
|
k1 = state.k1,
|
||||||
|
callback = state.callback,
|
||||||
|
amountSats = amountSats,
|
||||||
|
memo = state.defaultDescription
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { vm.resetReceiveState() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.PaymentReceived -> {
|
||||||
|
ReceiveSuccessContent(
|
||||||
|
amountSats = state.amountSats,
|
||||||
|
memo = state.memo,
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
onDone = { vm.resetReceiveState() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.Error -> {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Error",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
OutlinedButton(onClick = { vm.resetReceiveState() }) {
|
||||||
|
Text("Try Again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (receiveState is ReceiveState.InvoiceReady) {
|
||||||
|
ReceiveInvoiceContent(
|
||||||
|
state = receiveState as ReceiveState.InvoiceReady,
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
brightnessOn = brightnessOn,
|
||||||
|
onToggleBrightness = onToggleBrightness,
|
||||||
|
onReset = { vm.resetReceiveState() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun LnurlWithdrawSheet(
|
||||||
|
state : ReceiveState.LnurlWithdrawReady,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val fixedAmount = state.minSats == state.maxSats
|
||||||
|
|
||||||
|
var amountText by remember { mutableStateOf(state.maxSats.toString()) }
|
||||||
|
var amountError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun validate(): Long? {
|
||||||
|
val parsed = amountText.trim().toLongOrNull()
|
||||||
|
return when {
|
||||||
|
parsed == null || parsed <= 0 -> { amountError = "Enter a valid amount"; null }
|
||||||
|
parsed < state.minSats -> { amountError = "Minimum is ${state.minSats} sats"; null }
|
||||||
|
parsed > state.maxSats -> { amountError = "Maximum is ${state.maxSats} sats"; null }
|
||||||
|
else -> { amountError = null; parsed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Download,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Withdraw from",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.domain,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.defaultDescription.isNotBlank()) {
|
||||||
|
Card(colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)) {
|
||||||
|
Text(
|
||||||
|
text = state.defaultDescription,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedAmount) {
|
||||||
|
Card(colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Amount",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${state.maxSats} sats",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = { amountText = it; amountError = null },
|
||||||
|
label = { Text("Amount (sats)") },
|
||||||
|
supportingText = {
|
||||||
|
if (amountError != null) Text(amountError!!, color = MaterialTheme.colorScheme.error)
|
||||||
|
else Text("${state.minSats} – ${state.maxSats} sats")
|
||||||
|
},
|
||||||
|
isError = amountError != null,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val amount = if (fixedAmount) state.maxSats else validate()
|
||||||
|
if (amount != null) onConfirm(amount)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Withdraw ${if (fixedAmount) "${state.maxSats} sats" else ""}")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private enum class AmountUnit { SATS, FIAT }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReceiveIdleContent(
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
lightningAddress : String?,
|
||||||
|
showAddress : Boolean,
|
||||||
|
onToggleAddress : () -> Unit,
|
||||||
|
brightnessOn : Boolean,
|
||||||
|
onToggleBrightness : () -> Unit,
|
||||||
|
isLoading : Boolean,
|
||||||
|
onCreateInvoice : (Long, String) -> Unit
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
// Wrap the toggle so that opening the address card always clears focus + keyboard
|
||||||
|
val onToggleAddressWithDismiss = {
|
||||||
|
if (!showAddress) {
|
||||||
|
// About to show address — dismiss keyboard and clear focus first
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
onToggleAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
var step by remember { mutableIntStateOf(1) }
|
||||||
|
var confirmedAmountText by remember { mutableStateOf("") }
|
||||||
|
var confirmedUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Header row ────────────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Receive",
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
if (lightningAddress != null) {
|
||||||
|
IconButton(onClick = onToggleAddressWithDismiss) { // ← use wrapped version
|
||||||
|
Text(
|
||||||
|
text = "@",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (showAddress) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lightning address card ────────────────────────────────────────────
|
||||||
|
AnimatedVisibility(visible = showAddress && lightningAddress != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = lightningAddress!!,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
QrDisplayCard(
|
||||||
|
content = lightningAddress!!,
|
||||||
|
contentDescription = "Lightning address QR code",
|
||||||
|
clipLabel = "Lightning Address",
|
||||||
|
shareTitle = "Share Lightning Address",
|
||||||
|
textToCopy = lightningAddress!!,
|
||||||
|
brightnessOn = brightnessOn,
|
||||||
|
onToggleBrightness = onToggleBrightness
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 1) {
|
||||||
|
AmountStepContent(
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
addressShowing = showAddress,
|
||||||
|
onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper
|
||||||
|
onNext = { text, unit ->
|
||||||
|
confirmedAmountText = text
|
||||||
|
confirmedUnit = unit
|
||||||
|
step = 2
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val confirmedSats = when (confirmedUnit) {
|
||||||
|
AmountUnit.SATS -> confirmedAmountText.toDouble().toLong()
|
||||||
|
AmountUnit.FIAT -> (confirmedAmountText.toDouble() * (fiatRate ?: 1.0)).toLong()
|
||||||
|
}
|
||||||
|
MemoStepContent(
|
||||||
|
confirmedSats = confirmedSats,
|
||||||
|
fiatLabel = fiatLabel(confirmedSats, fiatRate, fiatCurrency),
|
||||||
|
isLoading = isLoading,
|
||||||
|
onCreateInvoice = { memo -> onCreateInvoice(confirmedSats, memo) },
|
||||||
|
onBack = { step = 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun AmountStepContent(
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
addressShowing : Boolean,
|
||||||
|
onCollapseAddress : () -> Unit,
|
||||||
|
onNext : (amountText: String, activeUnit: AmountUnit) -> Unit
|
||||||
|
) {
|
||||||
|
val showFiatToggle = fiatRate != null && fiatCurrency != null
|
||||||
|
|
||||||
|
var amountText by remember { mutableStateOf("") }
|
||||||
|
var amountError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var activeUnit by remember { mutableStateOf(AmountUnit.SATS) }
|
||||||
|
|
||||||
|
val parsedAmount = amountText.trim().toDoubleOrNull()
|
||||||
|
val amountValid = parsedAmount != null && parsedAmount > 0
|
||||||
|
|
||||||
|
val conversionLabel: String? = remember(amountText, activeUnit, fiatRate, fiatCurrency) {
|
||||||
|
if (!showFiatToggle) return@remember null
|
||||||
|
val number = amountText.trim().toDoubleOrNull() ?: return@remember null
|
||||||
|
when (activeUnit) {
|
||||||
|
AmountUnit.SATS -> fiatLabel(number.toLong(), fiatRate, fiatCurrency)
|
||||||
|
AmountUnit.FIAT -> "≈ ${(number * fiatRate!!).toLong()} sats"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = { amountText = it; amountError = null },
|
||||||
|
placeholder = {
|
||||||
|
Text(if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00")
|
||||||
|
},
|
||||||
|
isError = amountError != null,
|
||||||
|
supportingText = when {
|
||||||
|
amountError != null -> { { Text(amountError!!, color = MaterialTheme.colorScheme.error) } }
|
||||||
|
conversionLabel != null -> { { Text(conversionLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) } }
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onFocusChanged { if (it.isFocused && addressShowing) onCollapseAddress() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showFiatToggle) {
|
||||||
|
UnitWheelPicker(
|
||||||
|
units = listOf("sats", fiatCurrency),
|
||||||
|
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
|
||||||
|
onIndexSelected = { newIndex ->
|
||||||
|
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
|
||||||
|
if (newUnit != activeUnit) {
|
||||||
|
activeUnit = newUnit
|
||||||
|
amountText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.width(64.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!amountValid) amountError = "Enter a valid amount"
|
||||||
|
else { amountError = null; onNext(amountText, activeUnit) }
|
||||||
|
},
|
||||||
|
enabled = amountText.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Next")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun MemoStepContent(
|
||||||
|
confirmedSats : Long,
|
||||||
|
fiatLabel : String?,
|
||||||
|
isLoading : Boolean,
|
||||||
|
onCreateInvoice : (memo: String) -> Unit,
|
||||||
|
onBack : () -> Unit
|
||||||
|
) {
|
||||||
|
var memo by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
AmountDisplay(amountSats = confirmedSats, fiatLabel = fiatLabel)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = memo,
|
||||||
|
onValueChange = { memo = it },
|
||||||
|
label = { Text("Memo (optional)") },
|
||||||
|
placeholder = { Text("What's this for?") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onCreateInvoice(memo.trim()) },
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(if (isLoading) "Creating invoice…" else "Create Invoice")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("← Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
private fun ReceiveInvoiceContent(
|
||||||
|
state : ReceiveState.InvoiceReady,
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
brightnessOn : Boolean,
|
||||||
|
onToggleBrightness : () -> Unit,
|
||||||
|
onReset : () -> Unit
|
||||||
|
) {
|
||||||
|
var secondsLeft by remember {
|
||||||
|
mutableLongStateOf(
|
||||||
|
(state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.expiresAt) {
|
||||||
|
while (secondsLeft > 0L) {
|
||||||
|
delay(1_000L)
|
||||||
|
secondsLeft = (state.expiresAt - System.currentTimeMillis() / 1000L)
|
||||||
|
.coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isExpired = secondsLeft == 0L
|
||||||
|
val expiryLabel = "%d:%02d".format(secondsLeft / 60, secondsLeft % 60)
|
||||||
|
val fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// ── Amount ────────────────────────────────────────────────────────────
|
||||||
|
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel)
|
||||||
|
|
||||||
|
if (state.memo.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = state.memo,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR — weight(1f) claims all remaining vertical space ───────────────
|
||||||
|
// modifier targets the QrDisplayCard's outer Column (gives it the height)
|
||||||
|
// qrModifier targets the Image inside (fills that height, stays square)
|
||||||
|
QrDisplayCard(
|
||||||
|
content = state.bolt11.uppercase(),
|
||||||
|
contentDescription = "Lightning invoice QR code",
|
||||||
|
clipLabel = "Invoice",
|
||||||
|
shareTitle = "Share Invoice",
|
||||||
|
textToCopy = state.bolt11,
|
||||||
|
brightnessOn = brightnessOn,
|
||||||
|
onToggleBrightness = onToggleBrightness,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f) // outer Column gets all remaining height
|
||||||
|
.fillMaxWidth(),
|
||||||
|
qrModifier = Modifier
|
||||||
|
.fillMaxWidth() // Image fills the column width
|
||||||
|
.weight(1f) // Image also takes remaining height inside QrDisplayCard's Column
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Status ────────────────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (!isExpired) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
Text(
|
||||||
|
text = "Waiting for payment…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = expiryLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Invoice expired",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onReset,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(if (isExpired) "New Invoice" else "Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReceiveSuccessContent(
|
||||||
|
amountSats : Long,
|
||||||
|
memo : String?,
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
onDone : () -> Unit
|
||||||
|
) {
|
||||||
|
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(72.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// AmountDisplay renders both the sats line and the fiat subtitle
|
||||||
|
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||||
|
if (!memo.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(memo, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.example.gudariwallet.ui.send.Bolt11ConfirmCard
|
||||||
|
import com.example.gudariwallet.ui.send.LnurlInvoiceConfirmCard
|
||||||
|
import com.example.gudariwallet.ui.send.LnurlPayForm
|
||||||
|
import com.example.gudariwallet.util.SendInputDetector
|
||||||
|
import com.example.gudariwallet.util.feePpm
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SendScreen(vm: WalletViewModel) {
|
||||||
|
val sendState: SendState by vm.sendState.collectAsStateWithLifecycle()
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context as FragmentActivity
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Read rate directly from VM cache — same source HomeTab uses.
|
||||||
|
// Both are null when no currency is selected; fiat display is suppressed.
|
||||||
|
val fiatRate = vm.fiatRate // Double?
|
||||||
|
val fiatCurrency = vm.fiatCurrency // String?
|
||||||
|
|
||||||
|
DisposableEffect(Unit) { onDispose { vm.resetSendState() } }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Top
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Send", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (sendState !is SendState.PaymentSent) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = SendInputDetector.normalize(it) },
|
||||||
|
label = { Text("Invoice, LNURL, or Lightning Address") },
|
||||||
|
placeholder = { Text("lnbc… / lnurl1… / user@domain.com") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = sendState is SendState.Idle || sendState is SendState.Error,
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 4,
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onGo = { if (input.isNotBlank()) vm.scan(input) }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val state = sendState) {
|
||||||
|
is SendState.Idle -> {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val text = clipboard.getClipEntry()
|
||||||
|
?.clipData
|
||||||
|
?.getItemAt(0)
|
||||||
|
?.text
|
||||||
|
?.toString()
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
input = SendInputDetector.normalize(text)
|
||||||
|
vm.scan(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ContentPaste,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Paste")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { if (input.isNotBlank()) vm.scan(input) },
|
||||||
|
enabled = input.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Continue") }
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Scanning -> {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Resolving…")
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Bolt11Decoded -> {
|
||||||
|
Bolt11ConfirmCard(
|
||||||
|
state = state,
|
||||||
|
activity = activity,
|
||||||
|
vm = vm,
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.LnurlReady -> {
|
||||||
|
LnurlPayForm(
|
||||||
|
state = state,
|
||||||
|
vm = vm,
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.LnurlInvoiceReady -> {
|
||||||
|
LnurlInvoiceConfirmCard(
|
||||||
|
state = state,
|
||||||
|
activity = activity,
|
||||||
|
vm = vm,
|
||||||
|
fiatRate = fiatRate,
|
||||||
|
fiatCurrency = fiatCurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
is SendState.Paying -> {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Sending payment…")
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.PaymentSent -> {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
text = "✓",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Payment sent!", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${state.amountSats} sats",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
val feeText = when {
|
||||||
|
state.feeSats == null || state.feeSats == 0L -> "No fees"
|
||||||
|
else -> {
|
||||||
|
val ppm = feePpm(
|
||||||
|
amountMsat = state.amountSats * 1_000L,
|
||||||
|
feeMsat = state.feeSats * 1_000L
|
||||||
|
)
|
||||||
|
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
|
||||||
|
"Routing fee: ${state.feeSats} sat${if (state.feeSats == 1L) "" else "s"}$ppmSuffix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = feeText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { vm.resetSendState(); input = "" },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Send Another") }
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Error -> {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { if (input.isNotBlank()) vm.scan(input) },
|
||||||
|
enabled = input.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Try Again") }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { vm.resetSendState(); input = "" },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Clear") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import com.example.gudariwallet.api.LnurlScanResponse
|
||||||
|
import com.example.gudariwallet.api.PaymentDetailResponse
|
||||||
|
import com.example.gudariwallet.api.PaymentRecord
|
||||||
|
import com.example.gudariwallet.api.RouteHintHop
|
||||||
|
import com.example.gudariwallet.util.RouteHintRisk
|
||||||
|
import com.example.gudariwallet.util.SendInputType
|
||||||
|
|
||||||
|
sealed class UiState<out T> {
|
||||||
|
data object Loading : UiState<Nothing>()
|
||||||
|
data class Success<T>(val data: T) : UiState<T>()
|
||||||
|
data class Error(val message: String) : UiState<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ReceiveState {
|
||||||
|
data object Idle : ReceiveState()
|
||||||
|
data object AwaitingInvoice : ReceiveState()
|
||||||
|
data class InvoiceReady(
|
||||||
|
val bolt11 : String,
|
||||||
|
val paymentHash : String,
|
||||||
|
val amountSats : Long,
|
||||||
|
val memo : String,
|
||||||
|
val expiresAt : Long
|
||||||
|
) : ReceiveState()
|
||||||
|
data class PaymentReceived(
|
||||||
|
val amountSats: Long,
|
||||||
|
val memo : String?
|
||||||
|
) : ReceiveState()
|
||||||
|
data class Error(val message: String) : ReceiveState()
|
||||||
|
data class LnurlWithdrawReady(
|
||||||
|
val k1 : String,
|
||||||
|
val callback : String,
|
||||||
|
val defaultDescription : String,
|
||||||
|
val minSats : Long,
|
||||||
|
val maxSats : Long,
|
||||||
|
val domain : String,
|
||||||
|
val lnurl : String // original encoded string, for display
|
||||||
|
) : ReceiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
sealed class SendState {
|
||||||
|
data object Idle : SendState()
|
||||||
|
data object Scanning : SendState()
|
||||||
|
data class Bolt11Decoded(
|
||||||
|
override val bolt11 : String,
|
||||||
|
override val amountSats : Long,
|
||||||
|
override val memo : String,
|
||||||
|
override val payee : String? = null,
|
||||||
|
override val nodeAlias : String? = null,
|
||||||
|
override val routeRisk : RouteHintRisk? = null,
|
||||||
|
override val allRouteHints : List<RouteHintHop> = emptyList(),
|
||||||
|
override val routeHintAliases : Map<String, String> = emptyMap()
|
||||||
|
) : SendState(), DecodedInvoice
|
||||||
|
data class LnurlReady(
|
||||||
|
val callback : String,
|
||||||
|
val description : String,
|
||||||
|
val domain : String,
|
||||||
|
val minSats : Long,
|
||||||
|
val maxSats : Long,
|
||||||
|
val commentAllowed : Int,
|
||||||
|
val lnurl : String,
|
||||||
|
val rawRes : LnurlScanResponse
|
||||||
|
) : SendState()
|
||||||
|
data class LnurlInvoiceReady(
|
||||||
|
override val bolt11 : String,
|
||||||
|
override val amountSats : Long,
|
||||||
|
override val memo : String,
|
||||||
|
val domain : String,
|
||||||
|
val lnurl : String,
|
||||||
|
val rawRes : LnurlScanResponse,
|
||||||
|
val amountMsat : Long,
|
||||||
|
val comment : String?,
|
||||||
|
override val payee : String? = null,
|
||||||
|
override val nodeAlias : String? = null,
|
||||||
|
override val routeRisk : RouteHintRisk? = null,
|
||||||
|
override val allRouteHints : List<RouteHintHop> = emptyList(),
|
||||||
|
override val routeHintAliases : Map<String, String> = emptyMap()
|
||||||
|
) : SendState(), DecodedInvoice
|
||||||
|
data object Paying : SendState()
|
||||||
|
data class PaymentSent(
|
||||||
|
val amountSats : Long,
|
||||||
|
val feeSats : Long? = null
|
||||||
|
) : SendState()
|
||||||
|
data class Error(val message: String) : SendState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History list states ────────────────────────────────────────────────────────
|
||||||
|
sealed interface HistoryState {
|
||||||
|
data object Loading : HistoryState
|
||||||
|
data class Success(
|
||||||
|
val payments : List<PaymentRecord>,
|
||||||
|
val canLoadMore : Boolean,
|
||||||
|
val isRefreshing: Boolean = false
|
||||||
|
) : HistoryState
|
||||||
|
data class Error(val message: String) : HistoryState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment detail states ──────────────────────────────────────────────────────
|
||||||
|
sealed interface DetailState {
|
||||||
|
data object Idle : DetailState
|
||||||
|
data object Loading : DetailState
|
||||||
|
data class Partial(val record: PaymentRecord) : DetailState
|
||||||
|
data class Success(val detail: PaymentDetailResponse) : DetailState
|
||||||
|
data class Error(val message: String) : DetailState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Balance states ─────────────────────────────────────────────────────────────
|
||||||
|
sealed interface BalanceState {
|
||||||
|
data object Loading : BalanceState
|
||||||
|
data class Success(
|
||||||
|
val sats : Long,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val lastUpdated : Long = System.currentTimeMillis(),
|
||||||
|
val fiatAmount : Double? = null,
|
||||||
|
val fiatCurrency : String? = null
|
||||||
|
) : BalanceState
|
||||||
|
data class Error(
|
||||||
|
val message : String,
|
||||||
|
val staleSats: Long? = null
|
||||||
|
) : BalanceState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clipboard offer states ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed interface ClipboardOfferState {
|
||||||
|
data object None : ClipboardOfferState
|
||||||
|
data class Detected(
|
||||||
|
val raw : String,
|
||||||
|
val inputType: SendInputType
|
||||||
|
) : ClipboardOfferState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared interface for send states that represent a fully decoded invoice
|
||||||
|
* ready for user confirmation. Allows ConfirmCardContent to accept either
|
||||||
|
* Bolt11Decoded or LnurlInvoiceReady without knowing which it is.
|
||||||
|
*/
|
||||||
|
interface DecodedInvoice {
|
||||||
|
val amountSats : Long
|
||||||
|
val memo : String
|
||||||
|
val payee : String?
|
||||||
|
val nodeAlias : String?
|
||||||
|
val bolt11 : String
|
||||||
|
val routeRisk : RouteHintRisk?
|
||||||
|
val allRouteHints : List<RouteHintHop>
|
||||||
|
val routeHintAliases : Map<String, String>
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.QrCode
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.example.gudariwallet.MainActivity
|
||||||
|
import com.example.gudariwallet.util.SendInputType
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
// ── Tab destinations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
|
||||||
|
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
|
||||||
|
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ROUTE_HOME = "home"
|
||||||
|
private const val ROUTE_SCANNER = "scanner"
|
||||||
|
private const val ROUTE_HISTORY = "history"
|
||||||
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{paymentHash}"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WalletScreen(
|
||||||
|
vm : WalletViewModel,
|
||||||
|
pendingPaymentHash: MutableState<String?>,
|
||||||
|
pendingPaymentUri : MutableState<String?> // SESSION 09: deep link / intent URI
|
||||||
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val historyFactory = remember {
|
||||||
|
HistoryViewModelFactory(
|
||||||
|
repo = vm.repo,
|
||||||
|
aliasRepo = vm.aliasRepo,
|
||||||
|
paymentCache = vm.paymentCache,
|
||||||
|
fiatCurrency = vm.selectedCurrency,
|
||||||
|
fiatSatsPerUnit = vm.fiatSatsPerUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||||
|
&& currentRoute != ROUTE_SCANNER
|
||||||
|
&& currentRoute != ROUTE_HISTORY
|
||||||
|
|
||||||
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|
|
||||||
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
|
val hash = pendingPaymentHash.value
|
||||||
|
LaunchedEffect(hash) {
|
||||||
|
if (hash != null) {
|
||||||
|
navController.navigate("payment_detail/$hash") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
pendingPaymentHash.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = context as MainActivity
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
activity.windowFocusEvents.collect {
|
||||||
|
val cm = context.getSystemService(ClipboardManager::class.java)
|
||||||
|
val text = cm?.primaryClip?.getItemAt(0)
|
||||||
|
?.coerceToText(context)?.toString()
|
||||||
|
vm.checkClipboard(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
vm.navigateToReceive.collect {
|
||||||
|
Log.d("WalletScreen", "LNURL-WITHDRAW [NAV RECEIVED] switching to Receive tab")
|
||||||
|
navController.navigate(TabItem.Receive.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val intentUri = pendingPaymentUri.value
|
||||||
|
LaunchedEffect(intentUri) {
|
||||||
|
if (intentUri != null) {
|
||||||
|
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
|
||||||
|
pendingPaymentUri.value = null // consume immediately
|
||||||
|
vm.scan(intentUri)
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Auto-dismiss banner after 8 seconds
|
||||||
|
LaunchedEffect(clipboardOffer) {
|
||||||
|
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||||
|
delay(8_000)
|
||||||
|
vm.dismissClipboardOffer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
// ── Floating island nav bar: Receive | [Scan] | Send ──────────
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 6.dp,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Receive
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == TabItem.Receive.route,
|
||||||
|
icon = { Icon(TabItem.Receive.icon, contentDescription = TabItem.Receive.label) },
|
||||||
|
label = { Text(TabItem.Receive.label) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = {
|
||||||
|
if (currentRoute != TabItem.Receive.route) {
|
||||||
|
navController.navigate(TabItem.Receive.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scan — centre, elevated, not a nav destination
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { navController.navigate(ROUTE_SCANNER) },
|
||||||
|
modifier = Modifier.size(52.dp),
|
||||||
|
shape = CircleShape
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = "Scan",
|
||||||
|
modifier = Modifier.size(26.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = currentRoute == TabItem.Send.route,
|
||||||
|
icon = { Icon(TabItem.Send.icon, contentDescription = TabItem.Send.label) },
|
||||||
|
label = { Text(TabItem.Send.label) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
onClick = {
|
||||||
|
if (currentRoute != TabItem.Send.route) {
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = ROUTE_HOME,
|
||||||
|
modifier = if (isFullScreenDestination)
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
else
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
composable(ROUTE_HOME) {
|
||||||
|
HomeTab(
|
||||||
|
vm = vm,
|
||||||
|
onHistoryClick = {
|
||||||
|
navController.navigate(ROUTE_HISTORY) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(TabItem.Receive.route) { ReceiveScreen(vm) }
|
||||||
|
composable(TabItem.Send.route) { SendScreen(vm) }
|
||||||
|
|
||||||
|
// Scanner — full screen, no bottom chrome
|
||||||
|
composable(ROUTE_SCANNER) {
|
||||||
|
QrScannerScreen(
|
||||||
|
onScanned = { scanned ->
|
||||||
|
navController.popBackStack()
|
||||||
|
vm.scan(scanned)
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History — full screen, no bottom chrome, close → home
|
||||||
|
composable(
|
||||||
|
ROUTE_HISTORY,
|
||||||
|
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||||
|
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||||
|
|
||||||
|
) {
|
||||||
|
HistoryScreen(
|
||||||
|
vm = historyVm,
|
||||||
|
onClose = {
|
||||||
|
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||||
|
},
|
||||||
|
onPaymentClick = { payment ->
|
||||||
|
navController.navigate("payment_detail/${payment.paymentHash}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment detail — back → history (natural back stack)
|
||||||
|
composable(
|
||||||
|
route = ROUTE_PAYMENT_DETAIL,
|
||||||
|
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
|
||||||
|
val historyState by historyVm.state.collectAsState()
|
||||||
|
val payment = (historyState as? HistoryState.Success)
|
||||||
|
?.payments
|
||||||
|
?.firstOrNull { it.paymentHash == paymentHash }
|
||||||
|
|
||||||
|
if (payment != null) {
|
||||||
|
PaymentDetailScreen(
|
||||||
|
payment = payment,
|
||||||
|
vm = historyVm,
|
||||||
|
onBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(paymentHash) { historyVm.refresh() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clipboard banner — floats over content, slides in from top ────
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = clipboardOffer is ClipboardOfferState.Detected,
|
||||||
|
enter = slideInVertically(initialOffsetY = { -it }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { -it }),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.zIndex(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||||
|
val offer = clipboardOffer as ClipboardOfferState.Detected
|
||||||
|
val label = when (offer.inputType) {
|
||||||
|
is SendInputType.Bolt11 -> "Invoice"
|
||||||
|
is SendInputType.Lnurl -> "LNURL"
|
||||||
|
is SendInputType.LightningAddress -> "Lightning Address"
|
||||||
|
else -> "Payment"
|
||||||
|
}
|
||||||
|
val subLabel = when (offer.inputType) {
|
||||||
|
is SendInputType.Lnurl -> "Tap to open"
|
||||||
|
else -> "Tap to pay"
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
vm.dismissClipboardOffer()
|
||||||
|
vm.scan(offer.raw)
|
||||||
|
navController.navigate(TabItem.Send.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentPaste, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"$label detected in clipboard",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
subLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { vm.dismissClipboardOffer() }) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.gudariwallet.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.example.gudariwallet.api.LNbitsClient
|
||||||
|
import com.example.gudariwallet.data.LNbitsNodeAliasRepository
|
||||||
|
import com.example.gudariwallet.data.LNbitsWalletRepository
|
||||||
|
import com.example.gudariwallet.data.LnurlCacheRepository
|
||||||
|
import com.example.gudariwallet.data.PaymentCacheRepository
|
||||||
|
import com.example.gudariwallet.security.EncryptedSecretStore
|
||||||
|
import com.example.gudariwallet.service.NodeAliasService
|
||||||
|
|
||||||
|
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
val secrets = EncryptedSecretStore(app)
|
||||||
|
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
|
||||||
|
val api = LNbitsClient.create(baseUrl)
|
||||||
|
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
|
||||||
|
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
|
||||||
|
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
|
||||||
|
val lnurlCache = LnurlCacheRepository(app)
|
||||||
|
val paymentCache = PaymentCacheRepository(app)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return WalletViewModel(
|
||||||
|
context = app,
|
||||||
|
repo = repo,
|
||||||
|
aliasRepo = aliasRepo,
|
||||||
|
lnurlCache = lnurlCache,
|
||||||
|
paymentCache = paymentCache
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.example.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,78 @@
|
|||||||
|
package com.example.gudariwallet.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun UnitWheelPicker(
|
||||||
|
units : List<String>,
|
||||||
|
selectedIndex : Int,
|
||||||
|
onIndexSelected : (Int) -> Unit,
|
||||||
|
modifier : Modifier = Modifier // caller controls width entirely
|
||||||
|
) {
|
||||||
|
val itemHeightDp = 32.dp
|
||||||
|
val totalHeightDp = itemHeightDp * 3
|
||||||
|
|
||||||
|
val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)
|
||||||
|
val snapBehavior = rememberSnapFlingBehavior(lazyListState = listState)
|
||||||
|
|
||||||
|
LaunchedEffect(listState.isScrollInProgress) {
|
||||||
|
if (!listState.isScrollInProgress) {
|
||||||
|
val realIndex = listState.firstVisibleItemIndex.coerceIn(0, units.lastIndex)
|
||||||
|
if (realIndex != selectedIndex) onIndexSelected(realIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
flingBehavior = snapBehavior,
|
||||||
|
modifier = modifier.height(totalHeightDp), // width comes from caller
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
item(key = "ghost_top") {
|
||||||
|
Spacer(modifier = Modifier.height(itemHeightDp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items(count = units.size, key = { it }) { index ->
|
||||||
|
val isCentre = index == selectedIndex
|
||||||
|
val alpha = if (isCentre) 1f else 0.55f
|
||||||
|
val fontWeight = if (isCentre) FontWeight.SemiBold else FontWeight.Normal
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(itemHeightDp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { this.alpha = alpha },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = units[index],
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = fontWeight,
|
||||||
|
color = if (isCentre) MaterialTheme.colorScheme.onSurface
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "ghost_bottom") {
|
||||||
|
Spacer(modifier = Modifier.height(itemHeightDp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.gudariwallet.ui.receive
|
||||||
|
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a sats amount in bold with an optional fiat subtitle.
|
||||||
|
* Used in MemoStepContent, ReceiveInvoiceContent, and ReceiveSuccessContent.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AmountDisplay(
|
||||||
|
amountSats : Long,
|
||||||
|
fiatLabel : String?,
|
||||||
|
style : TextStyle = MaterialTheme.typography.titleLarge
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$amountSats sats",
|
||||||
|
style = style,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (fiatLabel != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = fiatLabel,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BrightnessToggleButton(
|
||||||
|
isOn : Boolean,
|
||||||
|
onToggle: () -> Unit
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onToggle) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isOn) Icons.Filled.BrightnessHigh
|
||||||
|
else Icons.Filled.BrightnessLow,
|
||||||
|
contentDescription = if (isOn) "Reduce brightness"
|
||||||
|
else "Boost brightness",
|
||||||
|
tint = if (isOn) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.example.gudariwallet.ui.receive
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.gudariwallet.util.IntentUtils
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.set
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR image followed by Copy / Share / Brightness action row.
|
||||||
|
*
|
||||||
|
* @param content What to encode into QR
|
||||||
|
* @param contentDescription Accessibility label for the image.
|
||||||
|
* @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address").
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun QrDisplayCard(
|
||||||
|
content : String,
|
||||||
|
contentDescription : String,
|
||||||
|
clipLabel : String,
|
||||||
|
shareTitle : String,
|
||||||
|
textToCopy : String,
|
||||||
|
brightnessOn : Boolean,
|
||||||
|
onToggleBrightness : () -> Unit,
|
||||||
|
modifier : Modifier = Modifier,
|
||||||
|
qrModifier : Modifier = Modifier.size(260.dp)
|
||||||
|
) {
|
||||||
|
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = qrModifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText(clipLabel, textToCopy))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy",
|
||||||
|
modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Share, contentDescription = "Share",
|
||||||
|
modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Share")
|
||||||
|
}
|
||||||
|
|
||||||
|
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val QR_BITMAP_SIZE = 512
|
||||||
|
|
||||||
|
private fun generateQrBitmap(content: String): Bitmap {
|
||||||
|
val bits = QRCodeWriter().encode(
|
||||||
|
content,
|
||||||
|
BarcodeFormat.QR_CODE,
|
||||||
|
QR_BITMAP_SIZE,
|
||||||
|
QR_BITMAP_SIZE
|
||||||
|
)
|
||||||
|
return createBitmap(QR_BITMAP_SIZE, QR_BITMAP_SIZE, Bitmap.Config.RGB_565).apply {
|
||||||
|
for (x in 0 until QR_BITMAP_SIZE)
|
||||||
|
for (y in 0 until QR_BITMAP_SIZE)
|
||||||
|
this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.example.gudariwallet.ui.send
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.example.gudariwallet.ui.DecodedInvoice
|
||||||
|
import com.example.gudariwallet.ui.SendState
|
||||||
|
import com.example.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.example.gudariwallet.ui.components.CopyIconButton
|
||||||
|
import com.example.gudariwallet.ui.components.DetailRow
|
||||||
|
import com.example.gudariwallet.util.fiatLabel
|
||||||
|
import com.example.gudariwallet.util.payingToLabel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun Bolt11ConfirmCard(
|
||||||
|
state : SendState.Bolt11Decoded,
|
||||||
|
activity : FragmentActivity,
|
||||||
|
vm : WalletViewModel,
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency: String?
|
||||||
|
) {
|
||||||
|
ConfirmCardContent(
|
||||||
|
title = "Invoice Details",
|
||||||
|
invoice = state,
|
||||||
|
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||||
|
onPay = {
|
||||||
|
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
|
||||||
|
vm.payBolt11(state.bolt11)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = { vm.resetSendState() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
internal fun LnurlInvoiceConfirmCard(
|
||||||
|
state : SendState.LnurlInvoiceReady,
|
||||||
|
activity : FragmentActivity,
|
||||||
|
vm : WalletViewModel,
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency: String?
|
||||||
|
) {
|
||||||
|
val label = payingToLabel(state.lnurl, state.domain)
|
||||||
|
ConfirmCardContent(
|
||||||
|
title = "Paying to $label",
|
||||||
|
invoice = state,
|
||||||
|
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
|
||||||
|
onPay = {
|
||||||
|
val label = payingToLabel(state.lnurl, state.domain)
|
||||||
|
vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") {
|
||||||
|
vm.payLnurlInvoice(state)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = { vm.resetSendState() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfirmCardContent(
|
||||||
|
title : String,
|
||||||
|
invoice : DecodedInvoice,
|
||||||
|
fiatLabel: String?,
|
||||||
|
onPay : () -> Unit,
|
||||||
|
onCancel : () -> Unit
|
||||||
|
) {
|
||||||
|
val amountSats = invoice.amountSats
|
||||||
|
val memo = invoice.memo
|
||||||
|
val nodeAlias = invoice.nodeAlias
|
||||||
|
val bolt11 = invoice.bolt11
|
||||||
|
val payee = invoice.payee
|
||||||
|
val routeRisk = invoice.routeRisk
|
||||||
|
val allRouteHints = invoice.allRouteHints
|
||||||
|
val routeHintAliases = invoice.routeHintAliases
|
||||||
|
val context = LocalContext.current
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(2.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
DetailRow(
|
||||||
|
label = "Amount",
|
||||||
|
value = "$amountSats sats",
|
||||||
|
subtitle = fiatLabel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (memo.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
DetailRow("Memo", memo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payee.isNullOrBlank()) {
|
||||||
|
val ambossUrl = "https://amboss.space/node/${payee}"
|
||||||
|
val destinationLabel = nodeAlias
|
||||||
|
?: "${payee.take(8)}…${payee.takeLast(8)}"
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
DetailRow(
|
||||||
|
label = "Destination",
|
||||||
|
value = destinationLabel,
|
||||||
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
onValueClick = {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
CopyIconButton("node_id", payee)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
DetailRow(
|
||||||
|
label = "Invoice",
|
||||||
|
value = bolt11.take(20) + "…" + bolt11.takeLast(6),
|
||||||
|
trailingContent = {
|
||||||
|
CopyIconButton("lightning_invoice", bolt11)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (routeRisk != null) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
RouteHintWarningBanner(
|
||||||
|
risk = routeRisk,
|
||||||
|
allRouteHints = allRouteHints,
|
||||||
|
routeHintAliases = routeHintAliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onPay,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
PayCancelButton(onCancel = onCancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PayButtonText(amountSats: Long, fiatLabel: String?) {
|
||||||
|
Text(
|
||||||
|
if (fiatLabel != null) "Pay $amountSats sats · $fiatLabel"
|
||||||
|
else "Pay $amountSats sats"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PayCancelButton(
|
||||||
|
onCancel : () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.example.gudariwallet.ui.send
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.gudariwallet.ui.SendState
|
||||||
|
import com.example.gudariwallet.ui.WalletViewModel
|
||||||
|
import com.example.gudariwallet.util.WalletConstants
|
||||||
|
import com.example.gudariwallet.util.fiatLabel
|
||||||
|
import com.example.gudariwallet.util.formatFiat
|
||||||
|
import com.example.gudariwallet.util.payingToLabel
|
||||||
|
import com.example.gudariwallet.util.satsToFiat
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun LnurlPayForm(
|
||||||
|
state : SendState.LnurlReady,
|
||||||
|
vm : WalletViewModel,
|
||||||
|
fiatRate : Double?,
|
||||||
|
fiatCurrency: String?
|
||||||
|
) {
|
||||||
|
var amount by remember { mutableStateOf(state.minSats.toString()) }
|
||||||
|
var comment by remember { mutableStateOf("") }
|
||||||
|
val isFixed = state.minSats == state.maxSats
|
||||||
|
|
||||||
|
// Long? — null means the field is empty or non-numeric, not zero.
|
||||||
|
// This is the key hardening: 0L can no longer silently pass through.
|
||||||
|
val sats: Long? = amount.toLongOrNull()
|
||||||
|
|
||||||
|
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
|
||||||
|
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text("Paying to ${payingToLabel(state.lnurl, state.domain)}", style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (state.description.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(state.description, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amount,
|
||||||
|
onValueChange = { if (it.all(Char::isDigit)) amount = it },
|
||||||
|
label = { Text("Amount (sats)") },
|
||||||
|
isError = amount.isNotBlank() && !isAmountValid,
|
||||||
|
supportingText = {
|
||||||
|
when {
|
||||||
|
// Show error when user has typed something invalid
|
||||||
|
amount.isNotBlank() && !isAmountValid -> {
|
||||||
|
Text(
|
||||||
|
text = "Enter an amount between ${state.minSats} and ${state.maxSats} sats",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Show fiat range when rate is available
|
||||||
|
fiatRate != null && fiatCurrency != null -> {
|
||||||
|
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
|
||||||
|
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
|
||||||
|
if (isFixed) Text(minFiat)
|
||||||
|
else Text("${state.minSats} – ${state.maxSats} sats · $minFiat – $maxFiat")
|
||||||
|
}
|
||||||
|
// Sats-only range when no rate
|
||||||
|
!isFixed -> Text("${state.minSats} – ${state.maxSats} sats")
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isFixed,
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||||
|
)
|
||||||
|
if (state.commentAllowed > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = comment,
|
||||||
|
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
|
||||||
|
label = { Text("Comment (optional)") },
|
||||||
|
supportingText = { Text("${comment.length}/${state.commentAllowed}") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Double-guard: sats must be non-null and in range before firing
|
||||||
|
if (sats != null && isAmountValid) {
|
||||||
|
vm.fetchLnurlInvoice(
|
||||||
|
rawRes = state.rawRes,
|
||||||
|
lnurl = state.lnurl,
|
||||||
|
domain = state.domain,
|
||||||
|
amountMsat = sats * WalletConstants.MSAT_PER_SAT,
|
||||||
|
comment = comment.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = isAmountValid,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (fiatLabel != null) "Pay $amount sats · $fiatLabel"
|
||||||
|
else "Pay $amount sats"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package com.example.gudariwallet.ui.send
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.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.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.example.gudariwallet.api.RouteHintHop
|
||||||
|
import com.example.gudariwallet.ui.components.CopyIconButton
|
||||||
|
import com.example.gudariwallet.util.RiskLevel
|
||||||
|
import com.example.gudariwallet.util.RouteHintRisk
|
||||||
|
import com.example.gudariwallet.util.baseFeeLabel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun RouteHintWarningBanner(
|
||||||
|
risk : RouteHintRisk,
|
||||||
|
allRouteHints : List<RouteHintHop>,
|
||||||
|
routeHintAliases: Map<String, String>,
|
||||||
|
) {
|
||||||
|
val isHigh = risk.level == RiskLevel.HIGH
|
||||||
|
val containerCol = if (isHigh) MaterialTheme.colorScheme.errorContainer
|
||||||
|
else MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
val contentCol = if (isHigh) MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
|
||||||
|
val offendingNode = routeHintAliases[risk.worstHop.publicKey]
|
||||||
|
?: risk.worstHop.publicKey.let { "${it.take(8)}…${it.takeLast(8)}" }
|
||||||
|
|
||||||
|
val estimatedFeeSats = (risk.estimatedFeeMsat + 999L) / 1_000L
|
||||||
|
|
||||||
|
var detailsExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = containerCol,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(10.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = contentCol,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (isHigh) "High routing fees in invoice"
|
||||||
|
else "Elevated routing fees in invoice",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = contentCol
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "This invoice forces payment through a private channel " +
|
||||||
|
"operated by $offendingNode.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentCol
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
val feeRateLine = buildString {
|
||||||
|
append("Effective fee rate: ${risk.effectivePpm} ppm")
|
||||||
|
append(" (${risk.worstHop.ppmFee} ppm")
|
||||||
|
if (risk.worstHop.baseFeeMsat > 0) append(" + ${baseFeeLabel(risk.worstHop.baseFeeMsat)} base")
|
||||||
|
append(")")
|
||||||
|
}
|
||||||
|
Text(text = feeRateLine, style = MaterialTheme.typography.bodySmall, color = contentCol)
|
||||||
|
Text(
|
||||||
|
text = "Estimated extra fee on this payment: " +
|
||||||
|
"~$estimatedFeeSats sat${if (estimatedFeeSats == 1L) "" else "s"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = contentCol
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allRouteHints.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { detailsExpanded = !detailsExpanded },
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (detailsExpanded) "Hide details ▴" else "See details ▾",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = contentCol
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (detailsExpanded) {
|
||||||
|
RouteHintDetailsPanel(
|
||||||
|
hops = allRouteHints,
|
||||||
|
aliases = routeHintAliases,
|
||||||
|
worstKey = risk.worstHop.publicKey,
|
||||||
|
tintColor = contentCol
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RouteHintDetailsPanel(
|
||||||
|
hops : List<RouteHintHop>,
|
||||||
|
aliases : Map<String, String>,
|
||||||
|
worstKey : String,
|
||||||
|
tintColor: Color
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(modifier = Modifier.padding(top = 6.dp)) {
|
||||||
|
HorizontalDivider(color = tintColor.copy(alpha = 0.3f))
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Private route hint hops",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tintColor.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
hops.forEachIndexed { index, hop ->
|
||||||
|
val alias = aliases[hop.publicKey]
|
||||||
|
val truncatedKey = "${hop.publicKey.take(8)}…${hop.publicKey.takeLast(8)}"
|
||||||
|
val displayName = alias ?: truncatedKey
|
||||||
|
val ambossUrl = "https://amboss.space/node/${hop.publicKey}"
|
||||||
|
val isWorst = hop.publicKey == worstKey
|
||||||
|
|
||||||
|
if (index > 0) Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = if (isWorst) androidx.compose.ui.text.font.FontWeight.Bold
|
||||||
|
else androidx.compose.ui.text.font.FontWeight.Normal
|
||||||
|
),
|
||||||
|
color = tintColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clickable {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
CopyIconButton("node_id", hop.publicKey, size = 28.dp, iconSize = 14.dp)
|
||||||
|
Text(
|
||||||
|
text = "${hop.ppmFee} ppm · ${baseFeeLabel(hop.baseFeeMsat)} base",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tintColor.copy(alpha = 0.85f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.widthIn(max = 160.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.gudariwallet.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// ui/theme/SemanticColors.kt
|
||||||
|
|
||||||
|
package com.example.gudariwallet.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SemanticColors(
|
||||||
|
val success : Color, // text / icon color
|
||||||
|
val successContainer : Color, // chip background
|
||||||
|
val onSuccessContainer: Color, // chip text
|
||||||
|
|
||||||
|
val warning : Color,
|
||||||
|
val warningContainer : Color,
|
||||||
|
val onWarningContainer: Color,
|
||||||
|
|
||||||
|
// Error is already in MaterialTheme.colorScheme — mirrored here for symmetry
|
||||||
|
val error : Color,
|
||||||
|
val errorContainer : Color,
|
||||||
|
val onErrorContainer : Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LightSemanticColors = SemanticColors(
|
||||||
|
success = Color(0xFF2E7D32),
|
||||||
|
successContainer = Color(0xFFC8E6C9),
|
||||||
|
onSuccessContainer = Color(0xFF1B5E20),
|
||||||
|
|
||||||
|
warning = Color(0xFF92400E),
|
||||||
|
warningContainer = Color(0xFFFFF3E0),
|
||||||
|
onWarningContainer = Color(0xFF4E2600),
|
||||||
|
|
||||||
|
error = Color(0xFFBA1A1A),
|
||||||
|
errorContainer = Color(0xFFFFDAD6),
|
||||||
|
onErrorContainer = Color(0xFF410002),
|
||||||
|
)
|
||||||
|
|
||||||
|
val DarkSemanticColors = SemanticColors(
|
||||||
|
success = Color(0xFFA5D6A7),
|
||||||
|
successContainer = Color(0xFF1B5E20),
|
||||||
|
onSuccessContainer = Color(0xFFC8E6C9),
|
||||||
|
|
||||||
|
warning = Color(0xFFFFB74D),
|
||||||
|
warningContainer = Color(0xFF4E2600),
|
||||||
|
onWarningContainer = Color(0xFFFFE0B2),
|
||||||
|
|
||||||
|
error = Color(0xFFFFB4AB),
|
||||||
|
errorContainer = Color(0xFF410002),
|
||||||
|
onErrorContainer = Color(0xFFFFDAD6),
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalSemanticColors = staticCompositionLocalOf { LightSemanticColors }
|
||||||
|
|
||||||
|
// Convenience accessor — use like: MaterialTheme.semanticColors.success
|
||||||
|
@Composable
|
||||||
|
fun semanticColors(): SemanticColors = LocalSemanticColors.current
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.example.gudariwallet.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40
|
||||||
|
|
||||||
|
/* Other default colors to override
|
||||||
|
background = Color(0xFFFFFBFE),
|
||||||
|
surface = Color(0xFFFFFBFE),
|
||||||
|
onPrimary = Color.White,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
onBackground = Color(0xFF1C1B1F),
|
||||||
|
onSurface = Color(0xFF1C1B1F),
|
||||||
|
*/
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GudariWalletTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
// Dynamic color is available on Android 12+
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.gudariwallet.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Set of Material typography styles to start with
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
/* Other default text styles to override
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import com.example.gudariwallet.api.LnurlScanResponse
|
||||||
|
import org.json.JSONObject
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human-readable message from a Retrofit [HttpException].
|
||||||
|
*
|
||||||
|
* LNbits error bodies look like: {"detail": "Insufficient balance"}
|
||||||
|
* Falls back to a generic message if the body can't be parsed.
|
||||||
|
*/
|
||||||
|
fun parseApiError(e: Throwable, fallback: String = "An error occurred"): String {
|
||||||
|
if (e !is HttpException) return e.message ?: fallback
|
||||||
|
val body = try {
|
||||||
|
e.response()?.errorBody()?.string()
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
if (!body.isNullOrBlank()) {
|
||||||
|
// Try {"detail": "..."} — standard LNbits format
|
||||||
|
try {
|
||||||
|
val detail = JSONObject(body).optString("detail", "")
|
||||||
|
if (detail.isNotBlank()) return friendlyMessage(detail, e.code())
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
// Try {"message": "..."} — some endpoints use this
|
||||||
|
try {
|
||||||
|
val msg = JSONObject(body).optString("message", "")
|
||||||
|
if (msg.isNotBlank()) return friendlyMessage(msg, e.code())
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return friendlyMessage(null, e.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lnurlProtocolError(raw: LnurlScanResponse?): String? {
|
||||||
|
raw ?: return null
|
||||||
|
if (raw.status?.uppercase() == "ERROR") {
|
||||||
|
return raw.reason?.ifBlank { null }
|
||||||
|
?: "LNURL server returned an error"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lnurlProtocolErrorFromJson(body: String?): String? {
|
||||||
|
if (body.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
if (json.optString("status").uppercase() == "ERROR") {
|
||||||
|
json.optString("reason").ifBlank { "LNURL server returned an error" }
|
||||||
|
} else null
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun friendlyMessage(detail: String?, httpCode: Int): String {
|
||||||
|
// Map known LNbits detail strings to user-friendly messages
|
||||||
|
return when {
|
||||||
|
detail == null -> genericForCode(httpCode)
|
||||||
|
detail.contains("Insufficient balance", ignoreCase = true) -> "Insufficient balance"
|
||||||
|
detail.contains("Invoice expired", ignoreCase = true) -> "Invoice has expired"
|
||||||
|
detail.contains("already paid", ignoreCase = true) -> "This invoice has already been paid"
|
||||||
|
detail.contains("Self-payment", ignoreCase = true) -> "Cannot pay your own invoice"
|
||||||
|
detail.contains("Failed to decode", ignoreCase = true) -> "Invalid invoice — could not decode"
|
||||||
|
detail.contains("No route", ignoreCase = true) -> "No route found to destination"
|
||||||
|
detail.contains("Failed to pay", ignoreCase = true) -> "Payment failed — no route found"
|
||||||
|
detail.contains("Unauthorized", ignoreCase = true) -> "Wallet key is invalid — check settings"
|
||||||
|
detail.contains("Rate limited", ignoreCase = true) -> "Too many requests — please wait"
|
||||||
|
httpCode == 401 -> "Wallet key is invalid — check settings"
|
||||||
|
httpCode == 429 -> "Too many requests — please wait"
|
||||||
|
httpCode >= 500 -> "Server error — please try again"
|
||||||
|
else -> detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun genericForCode(httpCode: Int): String = when (httpCode) {
|
||||||
|
400 -> "Invalid request"
|
||||||
|
401 -> "Wallet key is invalid — check settings"
|
||||||
|
404 -> "Not found"
|
||||||
|
429 -> "Too many requests — please wait"
|
||||||
|
in 500..599 -> "Server error — please try again"
|
||||||
|
else -> "An error occurred"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
|
||||||
|
object BiometricHelper {
|
||||||
|
|
||||||
|
private const val TAG = "BiometricHelper"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps a Context chain to find the hosting FragmentActivity, if any.
|
||||||
|
*/
|
||||||
|
fun resolveActivity(context: Context): FragmentActivity? {
|
||||||
|
var ctx: Context = context
|
||||||
|
while (ctx is ContextWrapper) {
|
||||||
|
if (ctx is FragmentActivity) return ctx
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the device has at least one enrolled biometric or a
|
||||||
|
* device credential (PIN/pattern/password) that can be used for auth.
|
||||||
|
*/
|
||||||
|
fun canAuthenticate(context: Context): Boolean =
|
||||||
|
BiometricManager.from(context).canAuthenticate(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a biometric prompt attached to [activity].
|
||||||
|
*
|
||||||
|
* [onSuccess] is called on the main thread when authentication succeeds.
|
||||||
|
* [onError] is called for hard errors (not for user-initiated cancels).
|
||||||
|
*/
|
||||||
|
fun prompt(
|
||||||
|
activity : FragmentActivity,
|
||||||
|
title : String = "Confirm Payment",
|
||||||
|
subtitle : String,
|
||||||
|
onSuccess: () -> Unit,
|
||||||
|
onError : (code: Int, msg: CharSequence) -> Unit = { _, _ -> }
|
||||||
|
) {
|
||||||
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(
|
||||||
|
result: BiometricPrompt.AuthenticationResult
|
||||||
|
) = onSuccess()
|
||||||
|
|
||||||
|
override fun onAuthenticationError(code: Int, msg: CharSequence) {
|
||||||
|
// Silently ignore user-initiated cancel / back press
|
||||||
|
if (code != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
code != BiometricPrompt.ERROR_NEGATIVE_BUTTON
|
||||||
|
) {
|
||||||
|
Log.e(TAG, "Biometric error $code: $msg")
|
||||||
|
onError(code, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val info = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setAllowedAuthenticators(
|
||||||
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
|
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
prompt.authenticate(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a satoshi amount to its fiat equivalent.
|
||||||
|
*
|
||||||
|
* @param sats Amount in satoshis.
|
||||||
|
* @param satsPerFiat How many sats equal 1 fiat unit (e.g. if BTC = $100,000 then
|
||||||
|
* satsPerFiat = 100,000,000 / 100,000 = 1,000 sats per dollar).
|
||||||
|
* @return Fiat amount as a Double.
|
||||||
|
*/
|
||||||
|
fun satsToFiat(sats: Long, satsPerFiat: Double): Double =
|
||||||
|
sats / satsPerFiat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a fiat amount for display with a currency symbol prefix.
|
||||||
|
*
|
||||||
|
* Tiers (bitcoin.design rules):
|
||||||
|
* - amount >= 1.00 → 2 dp, e.g. "$43.25"
|
||||||
|
* - 0.01 <= amount < 1.00 → 4 dp, e.g. "$0.0043"
|
||||||
|
* - amount < 0.01 (sub-cent)→ dynamic precision with "~" prefix,
|
||||||
|
* e.g. "~$0.0039" (2 significant digits after leading zeros)
|
||||||
|
* - amount == 0.0 → "$0.00"
|
||||||
|
*
|
||||||
|
* @param amount Fiat amount to format (always pass a positive value; sign is handled by caller).
|
||||||
|
* @param currency ISO 4217 currency code (e.g. "USD", "EUR").
|
||||||
|
* @return Formatted string with symbol, e.g. "$43.25" or "~$0.0039".
|
||||||
|
*/
|
||||||
|
fun formatFiat(amount: Double, currency: String): String {
|
||||||
|
val symbol = currencySymbol(currency)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
amount == 0.0 -> "${symbol}0.00"
|
||||||
|
amount >= 1.0 -> "$symbol${"%.2f".format(amount)}"
|
||||||
|
amount >= 0.01 -> "$symbol${"%.4f".format(amount)}"
|
||||||
|
else -> formatSubCent(amount, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts sats → fiat and formats in one call, applying bitcoin.design display rules.
|
||||||
|
*
|
||||||
|
* Use this everywhere you display a sat amount alongside a fiat equivalent.
|
||||||
|
*
|
||||||
|
* @param sats Absolute satoshi count (always positive; caller adds +/− sign).
|
||||||
|
* @param satsPerFiat Exchange rate: sats per 1 fiat unit.
|
||||||
|
* @param currency ISO 4217 currency code.
|
||||||
|
* @return Formatted fiat string, e.g. "$12.50", "~$0.0039", or "$0.00".
|
||||||
|
*/
|
||||||
|
fun formatFiatForSats(sats: Long, satsPerFiat: Double, currency: String): String =
|
||||||
|
formatFiat(satsToFiat(sats, satsPerFiat), currency)
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the sub-cent case (0 < amount < 0.01).
|
||||||
|
*
|
||||||
|
* Finds the first two significant (non-zero) digits after the decimal point
|
||||||
|
* and rounds to that precision, prefixing with "~" to signal approximation.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* 0.003887 → "~$0.0039"
|
||||||
|
* 0.000038 → "~$0.000038"
|
||||||
|
* 0.0000431 → "~$0.000043"
|
||||||
|
*/
|
||||||
|
private fun formatSubCent(amount: Double, symbol: String): String {
|
||||||
|
// Render with enough decimal places to capture at least 2 significant digits
|
||||||
|
val raw = "%.10f".format(amount) // e.g. "0.0000431000"
|
||||||
|
val dotIdx = raw.indexOf('.')
|
||||||
|
if (dotIdx == -1) return "~$symbol$raw"
|
||||||
|
|
||||||
|
val decimals = raw.substring(dotIdx + 1) // e.g. "0000431000"
|
||||||
|
val firstSigIdx = decimals.indexOfFirst { it != '0' }
|
||||||
|
if (firstSigIdx == -1) return "${symbol}0.00" // effectively zero
|
||||||
|
|
||||||
|
// Keep all leading zeros + 2 significant digits
|
||||||
|
val precision = firstSigIdx + 2
|
||||||
|
val rounded = "%.${precision}f".format(amount)
|
||||||
|
|
||||||
|
// Only add "~" if rounding actually changed the value
|
||||||
|
val isExact = "%.${precision}f".format(amount).toDoubleOrNull() == amount
|
||||||
|
return if (isExact) "$symbol$rounded" else "~$symbol$rounded"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display symbol for a given ISO 4217 currency code.
|
||||||
|
* Falls back to the currency code + space for unlisted currencies.
|
||||||
|
*/
|
||||||
|
private fun currencySymbol(currency: String): String = when (currency) {
|
||||||
|
"USD" -> "$"
|
||||||
|
"EUR" -> "€"
|
||||||
|
"GBP" -> "£"
|
||||||
|
"JPY" -> "¥"
|
||||||
|
"BRL" -> "R$"
|
||||||
|
"AUD" -> "A$"
|
||||||
|
"CAD" -> "C$"
|
||||||
|
"CHF" -> "Fr"
|
||||||
|
else -> "$currency "
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted fiat string for [sats] at the given rate, or null if
|
||||||
|
* rate or currency is unavailable. Suppresses display for zero/null amounts.
|
||||||
|
*/
|
||||||
|
fun fiatLabel(sats: Long?, fiatRate: Double?, fiatCurrency: String?, minSats: Long = 1L): String? =
|
||||||
|
if (sats != null && sats >= minSats && fiatRate != null && fiatCurrency != null)
|
||||||
|
formatFiat(satsToFiat(sats, fiatRate), fiatCurrency)
|
||||||
|
else null
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
object IntentUtils {
|
||||||
|
|
||||||
|
private val PAYMENT_SCHEMES = setOf(
|
||||||
|
"lightning", "bitcoin", "lnurlp", "lnurlw", "lnurlc", "lnurl"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun shareText(context:
|
||||||
|
Context, text: String, chooserTitle: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, text)
|
||||||
|
}
|
||||||
|
context.startActivity(Intent.createChooser(intent, chooserTitle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a raw payment URI string if the intent carries one, else null.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - ACTION_VIEW with a recognised scheme (BTCPay "Pay in wallet", browser links)
|
||||||
|
* - ACTION_SEND with text/plain (share sheet from another app)
|
||||||
|
*/
|
||||||
|
fun extractPaymentUri(intent: Intent?): String? {
|
||||||
|
if (intent == null) return null
|
||||||
|
|
||||||
|
// Case 1: direct URI intent (most common — BTCPay, wallet links)
|
||||||
|
if (intent.action == Intent.ACTION_VIEW) {
|
||||||
|
val uri: Uri? = intent.data
|
||||||
|
if (uri != null && uri.scheme?.lowercase() in PAYMENT_SCHEMES) {
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: share-sheet text (e.g. another app shares a BOLT-11 string)
|
||||||
|
if (intent.action == Intent.ACTION_SEND &&
|
||||||
|
intent.type == "text/plain") {
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim()
|
||||||
|
if (!text.isNullOrBlank()) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import kotlin.collections.toByteArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal bech32 decoder for LNURL strings (LUD-01).
|
||||||
|
*
|
||||||
|
* LNURL bech32 uses the standard bech32 charset and encodes a UTF-8 URL
|
||||||
|
* in the data part. We only need decode — no checksum verification needed
|
||||||
|
* because the decoded URL will fail to fetch if corrupted.
|
||||||
|
*/
|
||||||
|
object LnurlBech32 {
|
||||||
|
|
||||||
|
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a bech32-encoded LNURL string to its plaintext https:// URL.
|
||||||
|
* Returns null if the input is not valid bech32 or not an LNURL.
|
||||||
|
*/
|
||||||
|
fun decode(lnurl: String): String? = runCatching {
|
||||||
|
val lower = lnurl.trim().lowercase()
|
||||||
|
// Must have a separator
|
||||||
|
val sepIdx = lower.lastIndexOf('1')
|
||||||
|
if (sepIdx < 1) return null
|
||||||
|
|
||||||
|
val dataChars = lower.substring(sepIdx + 1)
|
||||||
|
// Strip the 6-char checksum
|
||||||
|
if (dataChars.length < 6) return null
|
||||||
|
val payload = dataChars.dropLast(6)
|
||||||
|
|
||||||
|
// Convert bech32 5-bit groups → 8-bit bytes
|
||||||
|
val decoded = convertBits(
|
||||||
|
data = payload.map { c ->
|
||||||
|
val idx = CHARSET.indexOf(c)
|
||||||
|
if (idx < 0) return null
|
||||||
|
idx
|
||||||
|
},
|
||||||
|
fromBits = 5,
|
||||||
|
toBits = 8,
|
||||||
|
pad = false
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
String(ByteArray(decoded.size) { decoded[it].toByte() }, Charsets.UTF_8)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun convertBits(
|
||||||
|
data : List<Int>,
|
||||||
|
fromBits: Int,
|
||||||
|
toBits : Int,
|
||||||
|
pad : Boolean
|
||||||
|
): List<Int>? {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
val out = mutableListOf<Int>()
|
||||||
|
val maxv = (1 shl toBits) - 1
|
||||||
|
|
||||||
|
for (value in data) {
|
||||||
|
if (value < 0 || value shr fromBits != 0) return null
|
||||||
|
acc = (acc shl fromBits) or value
|
||||||
|
bits += fromBits
|
||||||
|
while (bits >= toBits) {
|
||||||
|
bits -= toBits
|
||||||
|
out += (acc shr bits) and maxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pad) {
|
||||||
|
if (bits > 0) out += (acc shl (toBits - bits)) and maxv
|
||||||
|
} else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless helpers for parsing LNURL-pay step-1 metadata fields.
|
||||||
|
* Shared between [com.example.gudariwallet.data.LNbitsWalletRepository]
|
||||||
|
* and [com.example.gudariwallet.ui.WalletViewModel] so neither duplicates
|
||||||
|
* the logic.
|
||||||
|
*/
|
||||||
|
object LnurlMetadataParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the `text/plain` description from a LNURL-pay metadata JSON string.
|
||||||
|
* The metadata field is a JSON array of [type, value] pairs, e.g.:
|
||||||
|
* [["text/plain","Pay me"],["image/png;base64","..."]]
|
||||||
|
* Returns an empty string if the field is null, blank, or unparseable.
|
||||||
|
*/
|
||||||
|
fun parseDescription(metadata: String?): String {
|
||||||
|
if (metadata.isNullOrBlank()) return ""
|
||||||
|
return runCatching {
|
||||||
|
val array = JSONArray(metadata)
|
||||||
|
(0 until array.length())
|
||||||
|
.map { array.getJSONArray(it) }
|
||||||
|
.firstOrNull { it.getString(0) == "text/plain" }
|
||||||
|
?.getString(1) ?: ""
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the host portion of [callback] for display purposes.
|
||||||
|
* Returns an empty string if [callback] is null, blank, or not a valid URI.
|
||||||
|
*/
|
||||||
|
fun extractDomain(callback: String?): String {
|
||||||
|
if (callback.isNullOrBlank()) return ""
|
||||||
|
return runCatching { java.net.URI(callback).host ?: "" }.getOrDefault("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display label for a LNURL payment target.
|
||||||
|
* If [lnurl] is a Lightning Address (contains '@'), returns it as-is.
|
||||||
|
* Otherwise returns the [domain] of the LNURL endpoint.
|
||||||
|
*/
|
||||||
|
fun payingToLabel(lnurl: String, domain: String): String =
|
||||||
|
if ('@' in lnurl) lnurl else domain
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
object NotificationConstants {
|
||||||
|
|
||||||
|
// ── Channel IDs ───────────────────────────────────────────────────────────
|
||||||
|
const val CHANNEL_PAYMENTS = "gudari_payments"
|
||||||
|
const val CHANNEL_SERVICE_STATUS = "gudari_service"
|
||||||
|
|
||||||
|
// ── Notification IDs ──────────────────────────────────────────────────────
|
||||||
|
// ID 1 is reserved for the foreground service — Android requires a stable,
|
||||||
|
// non-zero ID for startForeground(). Keep it separate from user-facing IDs.
|
||||||
|
const val NOTIF_ID_SERVICE = 1
|
||||||
|
const val NOTIF_ID_PAYMENT_RECEIVED = 1001
|
||||||
|
const val NOTIF_ID_PAYMENT_SENT = 1002
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.example.gudariwallet.MainActivity
|
||||||
|
import com.example.gudariwallet.R
|
||||||
|
|
||||||
|
object NotificationHelper {
|
||||||
|
|
||||||
|
// ── Intent extra key — read by MainActivity ───────────────────────────────
|
||||||
|
// Public so MainActivity can reference it without a magic string.
|
||||||
|
const val EXTRA_PAYMENT_HASH = "extra_payment_hash"
|
||||||
|
|
||||||
|
// ── Channel setup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun createChannels(context: Context) {
|
||||||
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NotificationConstants.CHANNEL_PAYMENTS,
|
||||||
|
"Payments",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Incoming and outgoing Lightning payment alerts"
|
||||||
|
enableVibration(true)
|
||||||
|
enableLights(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NotificationConstants.CHANNEL_SERVICE_STATUS,
|
||||||
|
"Wallet Monitor",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Background service monitoring for incoming payments"
|
||||||
|
setShowBadge(false)
|
||||||
|
enableVibration(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User-facing notifications ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a high-priority notification for an incoming payment.
|
||||||
|
* Tapping opens MainActivity and navigates directly to the payment detail
|
||||||
|
* screen for [paymentHash].
|
||||||
|
*/
|
||||||
|
fun notifyPaymentReceived(context: Context, amountSats: Long, memo: String?, paymentHash: String) {
|
||||||
|
val title = "Payment received · +$amountSats sats"
|
||||||
|
val text = if (!memo.isNullOrBlank()) memo else "No memo"
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_PAYMENTS)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(mainActivityPendingIntent(context, paymentHash))
|
||||||
|
// BigTextStyle shows the full memo when the notification is expanded,
|
||||||
|
// in case it is long enough to be truncated in the compact view.
|
||||||
|
.setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(text)
|
||||||
|
.setSummaryText("+$amountSats sats")
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notify(context, NotificationConstants.NOTIF_ID_PAYMENT_RECEIVED, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts a default-priority notification for a successfully sent payment.
|
||||||
|
* Tapping opens MainActivity and navigates directly to the payment detail
|
||||||
|
* screen for [paymentHash].
|
||||||
|
*/
|
||||||
|
fun notifyPaymentSent(context: Context, amountSats: Long, paymentHash: String) {
|
||||||
|
val title = "Payment sent"
|
||||||
|
val text = "−$amountSats sats"
|
||||||
|
notify(
|
||||||
|
context,
|
||||||
|
NotificationConstants.NOTIF_ID_PAYMENT_SENT,
|
||||||
|
buildPaymentNotification(context, title, text, paymentHash)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Foreground service notification ───────────────────────────────────────
|
||||||
|
|
||||||
|
fun buildServiceNotification(context: Context): Notification {
|
||||||
|
return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_SERVICE_STATUS)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("Gudari Wallet")
|
||||||
|
.setContentText("Monitoring for incoming payments")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
// Service notification taps open the app with no specific destination
|
||||||
|
.setContentIntent(mainActivityPendingIntent(context, paymentHash = null))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dismiss ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun cancel(context: Context, notificationId: Int) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildPaymentNotification(
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
paymentHash: String
|
||||||
|
): Notification {
|
||||||
|
return NotificationCompat.Builder(context, NotificationConstants.CHANNEL_PAYMENTS)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(mainActivityPendingIntent(context, paymentHash))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a PendingIntent that opens MainActivity.
|
||||||
|
*
|
||||||
|
* If [paymentHash] is non-null, it is attached as [EXTRA_PAYMENT_HASH].
|
||||||
|
* MainActivity reads this in onCreate / onNewIntent and navigates to the
|
||||||
|
* payment detail screen.
|
||||||
|
*
|
||||||
|
* FLAG_UPDATE_CURRENT ensures that if a second notification arrives before
|
||||||
|
* the first is tapped, the hash is updated to the latest payment.
|
||||||
|
* Each notification ID gets its own requestCode so they don't overwrite
|
||||||
|
* each other's extras.
|
||||||
|
*/
|
||||||
|
private fun mainActivityPendingIntent(context: Context, paymentHash: String?): PendingIntent {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
if (paymentHash != null) {
|
||||||
|
putExtra(EXTRA_PAYMENT_HASH, paymentHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use the hash's hashCode as requestCode so each unique payment gets
|
||||||
|
// its own PendingIntent — prevents one notification's tap from
|
||||||
|
// overwriting another's extras.
|
||||||
|
val requestCode = paymentHash?.hashCode() ?: 0
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
requestCode,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(context: Context, id: Int, notification: Notification) {
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(context).notify(id, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import com.example.gudariwallet.api.RouteHintHop
|
||||||
|
|
||||||
|
data class RouteHintRisk(
|
||||||
|
val worstHop : RouteHintHop,
|
||||||
|
val effectivePpm : Long,
|
||||||
|
val estimatedFeeMsat : Long,
|
||||||
|
val level : RiskLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class RiskLevel { NONE, ELEVATED, HIGH }
|
||||||
|
|
||||||
|
object RouteHintAnalyzer {
|
||||||
|
|
||||||
|
private const val PPM_ELEVATED = 1_000L // 0.1%
|
||||||
|
private const val PPM_HIGH = 2_000L // 0.2%
|
||||||
|
private const val BASE_HIGH_MSAT = 10_000L // 10 sat flat fee on a single hop
|
||||||
|
|
||||||
|
fun analyze(amountMsat: Long, hints: List<RouteHintHop>): RouteHintRisk? {
|
||||||
|
if (hints.isEmpty()) return null
|
||||||
|
|
||||||
|
val refMsat = if (amountMsat > 0) amountMsat else 1_000L
|
||||||
|
|
||||||
|
val worstHop = hints.maxByOrNull { hop ->
|
||||||
|
hop.baseFeeMsat + (refMsat * hop.ppmFee) / 1_000_000L
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val estimatedFeeMsat = worstHop.baseFeeMsat + (refMsat * worstHop.ppmFee) / 1_000_000L
|
||||||
|
val effectivePpm = (estimatedFeeMsat * 1_000_000L) / refMsat
|
||||||
|
|
||||||
|
val level = when {
|
||||||
|
effectivePpm >= PPM_HIGH || worstHop.baseFeeMsat >= BASE_HIGH_MSAT -> RiskLevel.HIGH
|
||||||
|
effectivePpm >= PPM_ELEVATED -> RiskLevel.ELEVATED
|
||||||
|
else -> RiskLevel.NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level == RiskLevel.NONE) return null
|
||||||
|
|
||||||
|
return RouteHintRisk(
|
||||||
|
worstHop = worstHop,
|
||||||
|
effectivePpm = effectivePpm,
|
||||||
|
estimatedFeeMsat = estimatedFeeMsat,
|
||||||
|
level = level
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
import com.example.gudariwallet.util.WalletConstants.MSAT_PER_SAT
|
||||||
|
|
||||||
|
/** Convert millisatoshis to satoshis. e.g. 10_000L.msatToSat == 10L */
|
||||||
|
val Long.msatToSat: Long get() = this / MSAT_PER_SAT
|
||||||
|
|
||||||
|
/** Convert satoshis to millisatoshis. e.g. 10L.satToMsat == 10_000L */
|
||||||
|
val Long.satToMsat: Long get() = this * MSAT_PER_SAT
|
||||||
|
|
||||||
|
fun feePpm(amountMsat: Long, feeMsat: Long): Long? {
|
||||||
|
if (amountMsat == 0L) return null
|
||||||
|
return feeMsat * 1_000_000L / amountMsat
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a base fee in millisatoshis as a human-readable string.
|
||||||
|
* e.g. 1500 msat → "1 sat + 500 msat", 1000 msat → "1 sat", 500 msat → "500 msat"
|
||||||
|
*/
|
||||||
|
fun baseFeeLabel(baseFeeMsat: Long): String {
|
||||||
|
val sats = baseFeeMsat / 1_000L
|
||||||
|
val remainder = baseFeeMsat % 1_000L
|
||||||
|
return when {
|
||||||
|
sats > 0 && remainder > 0 -> "$sats sat + $remainder msat"
|
||||||
|
sats > 0 -> "$sats sat"
|
||||||
|
remainder > 0 -> "$remainder msat"
|
||||||
|
else -> "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
sealed class SendInputType {
|
||||||
|
data object Bolt11 : SendInputType()
|
||||||
|
data object Lnurl : SendInputType()
|
||||||
|
data object Lud17Url : SendInputType()
|
||||||
|
data object LightningAddress : SendInputType()
|
||||||
|
data object Bip21 : SendInputType()
|
||||||
|
data object Unknown : SendInputType()
|
||||||
|
}
|
||||||
|
|
||||||
|
object SendInputDetector {
|
||||||
|
private val lightningAddressRegex =
|
||||||
|
Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
|
||||||
|
private val lud17Schemes = setOf("lnurlp", "lnurlw", "lnurlc")
|
||||||
|
fun detect(raw: String): SendInputType {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
val lower = trimmed.lowercase()
|
||||||
|
if (lower.startsWith("bitcoin:")) return SendInputType.Bip21
|
||||||
|
if (lower.startsWith("keyauth://") || lower.startsWith("keyauth:")) {
|
||||||
|
return SendInputType.Unknown
|
||||||
|
}
|
||||||
|
val scheme = lower.substringBefore("://")
|
||||||
|
if (lower.contains("://") && scheme in lud17Schemes) {
|
||||||
|
return SendInputType.Lud17Url
|
||||||
|
}
|
||||||
|
val s = normalize(trimmed).lowercase()
|
||||||
|
return when {
|
||||||
|
s.startsWith("lnbc") -> SendInputType.Bolt11
|
||||||
|
s.startsWith("lnurl1") -> SendInputType.Lnurl
|
||||||
|
lightningAddressRegex.matches(trimmed) -> SendInputType.LightningAddress
|
||||||
|
else -> SendInputType.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalize(raw: String): String {
|
||||||
|
val s = raw.trim()
|
||||||
|
val lower = s.lowercase()
|
||||||
|
return when {
|
||||||
|
lower.startsWith("lightning://") -> s.substring("lightning://".length)
|
||||||
|
lower.startsWith("lightning:") -> s.substring("lightning:".length)
|
||||||
|
lower.startsWith("lnurl:") -> s.substring("lnurl:".length)
|
||||||
|
else -> s
|
||||||
|
}.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.gudariwallet.util
|
||||||
|
|
||||||
|
object WalletConstants {
|
||||||
|
|
||||||
|
// ── Unit conversion ───────────────────────────────────────────────────────
|
||||||
|
const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi
|
||||||
|
|
||||||
|
// ── Polling ───────────────────────────────────────────────────────────────
|
||||||
|
const val POLL_INTERVAL_MS = 3_000L // delay between payment status checks
|
||||||
|
const val POLL_MAX_ATTEMPTS = 200 // 200 × 3s = 10 minutes max
|
||||||
|
|
||||||
|
// ── WebSocket reconnect ───────────────────────────────────────────────────
|
||||||
|
const val WS_INITIAL_BACKOFF_MS = 1_000L // first retry delay
|
||||||
|
const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this
|
||||||
|
const val WS_MAX_ATTEMPTS = 5 // give up after 5 failed reconnects
|
||||||
|
|
||||||
|
// ── SharedPreferences ─────────────────────────────────────────────────────
|
||||||
|
const val PREFS_NAME = "lnbits_prefs"
|
||||||
|
const val PREFS_BASE_URL = "base_url"
|
||||||
|
const val PREFS_INVOICE_KEY_ENC = "invoice_key_enc"
|
||||||
|
const val PREFS_ADMIN_KEY_ENC = "admin_key_enc"
|
||||||
|
const val PREFS_ONBOARDED = "onboarded"
|
||||||
|
|
||||||
|
// ── Network ───────────────────────────────────────────────────────────────
|
||||||
|
const val CONNECT_TIMEOUT_S = 15L
|
||||||
|
const val READ_TIMEOUT_S = 30L
|
||||||
|
const val WRITE_TIMEOUT_S = 15L
|
||||||
|
|
||||||
|
// ── WebSocket close ───────────────────────────────────────────────────────
|
||||||
|
const val WS_CLOSE_NORMAL = 1000
|
||||||
|
|
||||||
|
// ── History pagination ────────────────────────────────────────────────────
|
||||||
|
const val PAYMENTS_PAGE_SIZE = 20
|
||||||
|
// ── Alias cache ───────────────────────────────────────────────────────────
|
||||||
|
const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold
|
||||||
|
const val ALIAS_PREFS_NAME = "alias_cache_prefs"
|
||||||
|
const val ALIAS_PREFS_KEY = "alias_cache"
|
||||||
|
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
|
||||||
|
// ── Payments cache ────────────────────────────────────────────────────────
|
||||||
|
const val PAYMENTS_PREFS_NAME = "payment_cache_prefs"
|
||||||
|
const val PAYMENTS_PREFS_KEY = "payment_list"
|
||||||
|
const val PAYMENTS_PREFS_SAVED_AT = "payment_list_saved_at"
|
||||||
|
const val PAYMENTS_CACHE_TTL_MS = 300_000L // 5 minutes — show stale, refresh in background
|
||||||
|
// ── Balance cache ─────────────────────────────────────────────────────────
|
||||||
|
const val BALANCE_PREFS_NAME = "balance_cache_prefs"
|
||||||
|
const val BALANCE_PREFS_KEY = "last_balance_sats"
|
||||||
|
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
|
||||||
|
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
|
||||||
|
// ── LNURL metadata cache ──────────────────────────────────────────────────
|
||||||
|
const val LNURL_CACHE_PREFS_NAME = "lnurl_cache_prefs"
|
||||||
|
const val LNURL_CACHE_PREFS_KEY = "lnurl_entries"
|
||||||
|
const val LNURL_CACHE_TTL_MS = 3_600_000L // 1 hour
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M13,2L4.5,13.5H11L10,22L19.5,10.5H13L13,2Z" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Gudari Wallet</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.GudariWallet" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.gudariwallet
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||||
|
toolchainVersion=21
|
||||||