diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..d32eafb --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,36 @@ +name: Build + +# Verifies that the app compiles. + +on: [workflow_dispatch, push, pull_request] + +permissions: {} + +jobs: + build: + name: "Build app" + runs-on: ubuntu-24.04 + + steps: + - name: "Checkout PR branch" + uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: "Set up JDK" + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: "Set up Android SDK" + uses: android-actions/setup-android@v3 + + - name: "Set up Android NDK" + uses: nttld/setup-ndk@v1 + with: + ndk-version: r26c + + - name: "Build app" + working-directory: newbuildsystem + run: ./kotlin build diff --git a/newbuildsystem/.gitignore b/newbuildsystem/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/newbuildsystem/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/newbuildsystem/jniLibs/arm64-v8a/libjnidispatch.so b/newbuildsystem/jniLibs/arm64-v8a/libjnidispatch.so new file mode 100644 index 0000000..cd7e671 Binary files /dev/null and b/newbuildsystem/jniLibs/arm64-v8a/libjnidispatch.so differ diff --git a/newbuildsystem/jniLibs/x86_64/libjnidispatch.so b/newbuildsystem/jniLibs/x86_64/libjnidispatch.so new file mode 100644 index 0000000..e594b88 Binary files /dev/null and b/newbuildsystem/jniLibs/x86_64/libjnidispatch.so differ diff --git a/newbuildsystem/justfile b/newbuildsystem/justfile new file mode 100644 index 0000000..ced6b17 --- /dev/null +++ b/newbuildsystem/justfile @@ -0,0 +1,6 @@ +default: + just --list --unsorted + +# Run the app in an emulator +run: + ./kotlin run diff --git a/newbuildsystem/kotlin b/newbuildsystem/kotlin new file mode 100755 index 0000000..1313759 --- /dev/null +++ b/newbuildsystem/kotlin @@ -0,0 +1,286 @@ +#!/bin/sh + +# +# Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +# + +# Possible environment variables: +# KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from. +# default: https://packages.jetbrains.team/maven/p/amper/amper +# KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the JRE to run the Kotlin CLI +# default: https:/ +# KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution +# KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation) +# KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application) +# KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value + +set -e -u + +# The version of the Kotlin Toolchain (and CLI) distribution to provision and use +kotlin_cli_version=0.11.0 +# Establish chain of trust from here by specifying exact checksum of Kotlin Toolchain (and CLI) distribution to be run +kotlin_cli_sha256=ff872a5bf42ad1a8fac90ccca6ac38a4d4a6aafefc39167860f66a77b1653d74 + +KOTLIN_CLI_DOWNLOAD_ROOT="${KOTLIN_CLI_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}" + +die() { + echo >&2 + echo "$@" >&2 + echo >&2 + exit 1 +} + +# usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE +# $1 SOURCE_MONIKER (e.g. url) +# $2 FILE +# $3 SHA hex string +# $4 SHA size in bits (256, 512, ...) +check_sha() { + sha_size=$4 + if command -v shasum >/dev/null 2>&1; then + echo "$3 *$2" | shasum -a "$sha_size" --status -c || { + die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')" + } + return 0 + fi + + shaNsumCommand="sha${sha_size}sum" + if command -v "$shaNsumCommand" >/dev/null 2>&1; then + # discard the output as sha*sum may print redundant warnings in some versions + echo "$3 *$2" | $shaNsumCommand -w -c >/dev/null 2>&1 || { + die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')" + } + return 0 + fi + + echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them" + return 1 +} + + +download_and_extract() { + moniker="$1" + file_url="$2" + file_sha="$3" + sha_size="$4" + cache_dir="$5" + extract_dir="$6" + show_banner_on_cache_miss="$7" + + if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then + # Everything is up-to-date in $extract_dir, do nothing + return 0; + fi + + mkdir -p "$cache_dir" + + # Take a lock for the download of this file + short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh + download_lock_file="$cache_dir/download-${short_sha}.lock" + process_lock_file="$cache_dir/download-${short_sha}.$$.lock" + echo $$ >"$process_lock_file" + while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do + lock_owner=$(cat "$download_lock_file" 2>/dev/null || true) + # We use `kill -0` instead of `ps -p` as the first one is more portable + if [ -n "$lock_owner" ] && kill -0 "$lock_owner" >/dev/null; then + echo "Another Kotlin CLI instance (pid $lock_owner) is downloading $moniker. Awaiting the result..." + sleep 1 + elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then + rm -f "$download_lock_file" + # We don't want to simply loop again here, because multiple concurrent processes may face this at the same time, + # which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just + # ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically + # reduced because it would involve 4 processes with perfect timing. We can revisit this later. + die "Another Kotlin CLI instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again." + fi + done + + # shellcheck disable=SC2064 + trap "rm -f \"$download_lock_file\"" EXIT + rm -f "$process_lock_file" + + unlock_and_cleanup() { + rm -f "$download_lock_file" + trap - EXIT + return 0 + } + + if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then + # Everything is up-to-date in $extract_dir, just release the lock + unlock_and_cleanup + return 0; + fi + + if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${KOTLIN_CLI_NO_WELCOME_BANNER:-}" ]; then + echo + cat </dev/null 2>&1; then + if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi + # shellcheck disable=SC2086 + curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url" + elif command -v wget >/dev/null 2>&1; then + if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi + wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url" + else + die "ERROR: Please install 'wget' or 'curl', as one of them is required to download $moniker" + fi + + check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size" + + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + + case "$file_url" in + *".zip") + if command -v unzip >/dev/null 2>&1; then + unzip -q "$temp_file" -d "$extract_dir" + else + die "ERROR: Please install 'unzip', which is required to extract $moniker" + fi ;; + *) + if command -v tar >/dev/null 2>&1; then + tar -x -f "$temp_file" -C "$extract_dir" + else + die "ERROR: Please install 'tar', which is required to extract $moniker" + fi ;; + esac + + rm -f "$temp_file" + + echo "$file_sha" >"$extract_dir/.flag" + + # Unlock and cleanup the lock file + unlock_and_cleanup + + echo "Download complete." + echo +} + + +# ********** Project-local version detection ********** + +# 1. Search upwards for an executable `amper` file and/or `project.yaml` +# Sets wrapper_script to the found wrapper path, or empty string if not found. +find_project_context() { + wrapper_script="" + this_script="$(realpath "$0")" + project_dir=$(pwd) + while [ "$project_dir" != "/" ] && [ -n "$project_dir" ]; do + wrapper_candidate="$project_dir/kotlin" + if [ "$this_script" = "$wrapper_candidate" ]; then + # Found itself (local wrapper case), no need to update any version or search further. + return 1 + fi + + if [ -f "$wrapper_candidate" ] && [ -x "$wrapper_candidate" ]; then + # Found the wrapper — check that a project context exists alongside it + if [ -f "$project_dir/project.yaml" ] || [ -f "$project_dir/module.yaml" ]; then + wrapper_script="$wrapper_candidate" + return 0 + else + echo "WARNING: Found wrapper script '$wrapper_candidate', but no project.yaml or module.yaml near it. Skipping." >&2 + # Continue the search + fi + elif [ -f "$project_dir/project.yaml" ]; then + # Found project.yaml but no executable wrapper alongside it + echo "WARNING: Found a project.yaml in '$project_dir', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version." >&2 + return 1 + fi + + project_dir=$(dirname "$project_dir") + done + # Do not check root '/' - it's an unlikely candidate for a project + + return 1 +} + +parse_project_context() { + # Parse kotlin_cli_version and kotlin_cli_sha256 from "$wrapper_script" without executing it. + parsed_kotlin_cli_version=$( + sed -n 's/^kotlin_cli_version=\([A-Za-z0-9._+-]\{1,\}\)[[:space:]]*$/\1/p' "$wrapper_script" \ + | head -n 1 + ) + parsed_kotlin_cli_sha256=$( + sed -n 's/^kotlin_cli_sha256=\([0-9a-fA-F]\{64\}\)[[:space:]]*$/\1/p' "$wrapper_script" \ + | head -n 1 + ) + + if [ -z "$parsed_kotlin_cli_version" ]; then + echo "ERROR: Suspicious local wrapper script: failed to detect the distribution version in '$wrapper_script'" >&2 + return 1 + fi + if [ -z "$parsed_kotlin_cli_sha256" ]; then + echo "ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '$wrapper_script'" >&2 + return 1 + fi + + # overwrite builtin values and proceed + kotlin_cli_version=$parsed_kotlin_cli_version + kotlin_cli_sha256=$parsed_kotlin_cli_sha256 + return 0 +} + +if [ -z "${KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION:-}" ]; then + find_project_context && parse_project_context +fi + +# ********** System detection ********** + +kernelName=$(uname -s) +case "$kernelName" in + Darwin* ) + default_kotlin_cli_cache_dir="$HOME/Library/Caches/JetBrains/Kotlin/cli" + ;; + Linux* ) + default_kotlin_cli_cache_dir="$HOME/.cache/JetBrains/Kotlin/cli" + ;; + CYGWIN* | MSYS* | MINGW* ) + if command -v cygpath >/dev/null 2>&1; then + default_kotlin_cli_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Kotlin\cli") + else + die "The 'cypath' command is not available, but the Kotlin CLI needs it. Use kotlin.bat instead, or try a Cygwin or MSYS environment." + fi + ;; + *) + die "Unsupported platform $kernelName" + ;; +esac + +kotlin_cli_cache_dir="${KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:-$default_kotlin_cli_cache_dir}" + +# ********** Provision the Kotlin Toolchain distribution ********** + +kotlin_cli_url="$KOTLIN_CLI_DOWNLOAD_ROOT/org/jetbrains/kotlin/kotlin-cli/$kotlin_cli_version/kotlin-cli-$kotlin_cli_version-dist.tgz" +kotlin_cli_target_dir="$kotlin_cli_cache_dir/kotlin-cli-$kotlin_cli_version" +download_and_extract "Kotlin Toolchain distribution v$kotlin_cli_version" "$kotlin_cli_url" "$kotlin_cli_sha256" 256 "$kotlin_cli_cache_dir" "$kotlin_cli_target_dir" "true" + +# ********** Launch the Kotlin CLI ********** + +launcher_script="$kotlin_cli_target_dir/bin/launcher.sh" + +KOTLIN_CLI_WRAPPER_PATH="$(realpath "$0")" \ +exec /bin/sh "$launcher_script" "$@" diff --git a/newbuildsystem/kotlin.bat b/newbuildsystem/kotlin.bat new file mode 100644 index 0000000..7cd0acc --- /dev/null +++ b/newbuildsystem/kotlin.bat @@ -0,0 +1,257 @@ +@echo off + +@rem +@rem Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +@rem + +@rem Possible environment variables: +@rem KOTLIN_CLI_DOWNLOAD_ROOT Maven repository to download the Kotlin CLI dist from +@rem default: https://packages.jetbrains.team/maven/p/amper/amper +@rem KOTLIN_CLI_JRE_DOWNLOAD_ROOT Url prefix to download the Kotlin CLI JRE from. +@rem default: https:/ +@rem KOTLIN_CLI_BOOTSTRAP_CACHE_DIR Cache directory to store the extracted JRE and Kotlin CLI distribution +@rem KOTLIN_CLI_JAVA_HOME JRE to run the Kotlin CLI itself (optional, does not affect compilation) +@rem KOTLIN_CLI_JAVA_OPTIONS JVM options to pass to the JVM running the Kotlin CLI (does not affect the user's application) +@rem KOTLIN_CLI_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value + +setlocal + +@rem The version of the Kotlin Toolchain distribution to provision and use +set kotlin_cli_version=0.11.0 +@rem Establish chain of trust from here by specifying the exact checksum of the Kotlin Toolchain distribution to be run +set kotlin_cli_sha256=ff872a5bf42ad1a8fac90ccca6ac38a4d4a6aafefc39167860f66a77b1653d74 + +if not defined KOTLIN_CLI_DOWNLOAD_ROOT set KOTLIN_CLI_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper +if not defined KOTLIN_CLI_BOOTSTRAP_CACHE_DIR set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Kotlin\cli +@rem remove trailing \ if present +if [%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~-1%] EQU [\] set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR:~0,-1% + +goto :after_function_declarations + +REM ********** Download and extract any zip or .tar.gz archive ********** + +:download_and_extract +setlocal + +set moniker=%~1 +set url=%~2 +set target_dir=%~3 +set sha=%~4 +set sha_size=%~5 +set show_banner_on_cache_miss=%~6 + +set flag_file=%target_dir%\.flag +if exist "%flag_file%" ( + set /p current_flag=<"%flag_file%" + if "%current_flag%" == "%sha%" exit /b +) + +setlocal enableDelayedExpansion +set NL=^ + + +@rem two empty lines required above for the NL character + +@rem We have to build the welcome banner here as an env var because we +@rem can't pass a multiline string through the single line powershell +set welcome_banner=!NL! ^ +Welcome to !NL! ^ + !NL! ^ +@@@ @@@@ @@@ @@@ !NL! ^ +@@@ @@@@ @@@ @@@ @@@ !NL! ^ +@@@ #@@@^" ,@@@ @@@ !NL! ^ +@@@ ,@@@%% ,@@@@@@@, @@@@@@@@@@ @@@ @@@ @@@ ,@@@@@, !NL! ^ +@@@ @@@@ @@@@@%%^"%%@@@@@ @@@@@@@@@@ @@@ @@@ @@@@@@%%%%@@@@@ !NL! ^ +@@@@@@%% @@@%% %%@@@ @@@ @@@ @@@ @@@@ %%@@%% !NL! ^ +@@@ @@@@= #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ @@@@ #@@@ @@@# @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ *@@@%% @@@@ @@@@ @@@ @@@ @@@ @@@ @@@ !NL! ^ +@@@ %%@@@= %%@@@@*,*@@@@%% @@@@### @@@ @@@ @@@ @@@ !NL! ^ +@@@ @@@@ ^"@@@@@@@^" %%@@@@@ @@@ @@@ @@@ @@@ !NL! + +@rem This multiline string is actually passed as a single line to powershell, meaning #-comments are not possible. +@rem So here are a few comments about the code below: +@rem - we need to support both .zip and .tar.gz archives (for the Kotlin Toolchain distribution and the JRE) +@rem - tar should be present in all Windows machines since 2018 (and usable from both cmd and powershell) +@rem - tar requires the destination dir to exist +@rem - We use (New-Object Net.WebClient).DownloadFile instead of Invoke-WebRequest for performance. See the issue +@rem https://github.com/PowerShell/PowerShell/issues/16914, which is still not fixed in Windows PowerShell 5.1 +@rem - DownloadFile requires the directories in the destination file's path to exist +set download_and_extract_ps1= ^ +Set-StrictMode -Version 3.0; ^ +$ErrorActionPreference = 'Stop'; ^ + ^ +$createdNew = $false; ^ +$lock = New-Object System.Threading.Mutex($true, ('Global\kotlin-cli-bootstrap.' + '%target_dir%'.GetHashCode().ToString()), [ref]$createdNew); ^ +if (-not $createdNew) { ^ + Write-Host 'Another Kotlin CLI instance is bootstrapping. Waiting for our turn...'; ^ + [void]$lock.WaitOne(); ^ +} ^ + ^ +try { ^ + if ((Get-Content '%flag_file%' -ErrorAction Ignore) -ne '%sha%') { ^ + if (('%show_banner_on_cache_miss%' -eq 'true') -and [string]::IsNullOrEmpty('%KOTLIN_CLI_NO_WELCOME_BANNER%')) { ^ + Write-Host \"$env:welcome_banner\"; ^ + Write-Host ''; ^ + Write-Host 'This is the first run of the Kotlin CLI v%kotlin_cli_version%, so we need to download the Kotlin Toolchain.'; ^ + Write-Host 'Please give us a few seconds now, subsequent runs will be faster.'; ^ + Write-Host ''; ^ + } ^ + $temp_file = '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\' + [System.IO.Path]::GetRandomFileName(); ^ + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ^ + Write-Host 'Downloading %moniker%...'; ^ + [void](New-Item '%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%' -ItemType Directory -Force); ^ + if (Get-Command curl.exe -errorAction SilentlyContinue) { ^ + curl.exe -L --silent --show-error --fail --output $temp_file '%url%'; ^ + } else { ^ + (New-Object Net.WebClient).DownloadFile('%url%', $temp_file); ^ + } ^ + ^ + $actualSha = (Get-FileHash -Algorithm SHA%sha_size% -Path $temp_file).Hash.ToString(); ^ + if ($actualSha -ne '%sha%') { ^ + $writeErr = if ($Host.Name -eq 'ConsoleHost') { [Console]::Error.WriteLine } else { $host.ui.WriteErrorLine } ^ + $writeErr.Invoke(\"ERROR: Checksum mismatch for $temp_file (downloaded from %url%): expected checksum %sha% but got $actualSha\"); ^ + exit 1; ^ + } ^ + ^ + if (Test-Path '%target_dir%') { ^ + Remove-Item '%target_dir%' -Recurse; ^ + } ^ + if ($temp_file -like '*.zip') { ^ + Add-Type -A 'System.IO.Compression.FileSystem'; ^ + [IO.Compression.ZipFile]::ExtractToDirectory($temp_file, '%target_dir%'); ^ + } else { ^ + [void](New-Item '%target_dir%' -ItemType Directory -Force); ^ + tar -xzf $temp_file -C '%target_dir%'; ^ + } ^ + Remove-Item $temp_file; ^ + ^ + Set-Content '%flag_file%' -Value '%sha%'; ^ + Write-Host 'Download complete.'; ^ + Write-Host ''; ^ + } ^ +} ^ +finally { ^ + $lock.ReleaseMutex(); ^ +} + +rem We reset the PSModulePath in case this batch script was called from PowerShell Core +rem See https://github.com/PowerShell/PowerShell/issues/18108#issuecomment-2269703022 +set PSModulePath= +set powershell=%SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe +"%powershell%" -NonInteractive -NoProfile -NoLogo -Command %download_and_extract_ps1% +if errorlevel 1 exit /b 1 +exit /b 0 + +:find_project_context +@rem Search upwards for a kotlin.bat wrapper file and/or project.yaml +@rem Sets wrapper_script to the found wrapper path, or empty string if not found. +@rem Returns errorlevel 0 if a valid wrapper (that is not this script itself) was found, 1 otherwise. +set wrapper_script= +set this_script=%~f0 +set project_dir=%CD% + +:find_loop +set wrapper_candidate=%project_dir%\kotlin.bat +if "%this_script%"=="%wrapper_candidate%" ( + @rem Found itself (local wrapper case), no need to update any version or search further. + exit /b 1 +) + +if exist "%wrapper_candidate%" ( + @rem Found a wrapper — check that a project context exists alongside it + if exist "%project_dir%\project.yaml" ( + set wrapper_script=%wrapper_candidate% + exit /b 0 + ) + if exist "%project_dir%\module.yaml" ( + set wrapper_script=%wrapper_candidate% + exit /b 0 + ) + echo WARNING: Found wrapper script '%wrapper_candidate%', but no project.yaml or module.yaml near it. Skipping. >&2 + @rem Continue the search + goto :find_next_parent +) + +if exist "%project_dir%\project.yaml" ( + @rem Found project.yaml but no wrapper alongside it + echo WARNING: Found a project.yaml in '%project_dir%', but the wrapper script is missing; using Kotlin Toolchain v$kotlin_cli_version. >&2 + exit /b 1 +) + +:find_next_parent +@rem Move to parent directory +for %%P in ("%project_dir%\..") do set parent_dir=%%~fP +if "%parent_dir%"=="%project_dir%" ( + @rem Reached the root, stop searching + exit /b 1 +) +set project_dir=%parent_dir% +goto :find_loop + +:parse_project_context +@rem Parse kotlin_cli_version and kotlin_cli_sha256 from the found wrapper_script without executing it. +set parsed_kotlin_cli_version= +set parsed_kotlin_cli_sha256= + +for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_version=[A-Za-z0-9._+-]*$" "%wrapper_script%"') do ( + if not defined parsed_kotlin_cli_version set parsed_kotlin_cli_version=%%A +) +for /f "tokens=2 delims==" %%A in ('findstr /r /c:"^set kotlin_cli_sha256=[0-9a-fA-F]*$" "%wrapper_script%"') do ( + if not defined parsed_kotlin_cli_sha256 set parsed_kotlin_cli_sha256=%%A +) + +if not defined parsed_kotlin_cli_version ( + echo ERROR: Suspicious local wrapper script: failed to detect the distribution version in '%wrapper_script%' >&2 + exit /b 1 +) +if not defined parsed_kotlin_cli_sha256 ( + echo ERROR: Suspicious local wrapper script: failed to detect the distribution checksum in '%wrapper_script%' >&2 + exit /b 1 +) + +@rem Overwrite builtin values and proceed +set kotlin_cli_version=%parsed_kotlin_cli_version% +set kotlin_cli_sha256=%parsed_kotlin_cli_sha256% +exit /b 0 + +:fail +echo ERROR: Kotlin CLI bootstrap failed, see errors above +exit /b 1 + +:after_function_declarations + +REM ********** Project-local version detection ********** + +if defined KOTLIN_CLI_WRAPPER_ALWAYS_USE_INTRINSIC_VERSION goto :after_local_version_detection + +call :find_project_context +if errorlevel 1 goto :after_local_version_detection +call :parse_project_context +if errorlevel 1 goto fail +:after_local_version_detection + +REM ********** Provision the Kotlin Toolchain distribution ********** + +set kotlin_cli_url=%KOTLIN_CLI_DOWNLOAD_ROOT%/org/jetbrains/kotlin/kotlin-cli/%kotlin_cli_version%/kotlin-cli-%kotlin_cli_version%-dist.tgz +set kotlin_cli_target_dir=%KOTLIN_CLI_BOOTSTRAP_CACHE_DIR%\kotlin-cli-%kotlin_cli_version% +call :download_and_extract "Kotlin Toolchain distribution v%kotlin_cli_version%" "%kotlin_cli_url%" "%kotlin_cli_target_dir%" "%kotlin_cli_sha256%" "256" "true" +if errorlevel 1 goto fail + +REM ********** Launch the Kotlin CLI ********** + +rem Determine the correct busybox binary based on architecture +if "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64a.exe +) else if "%PROCESSOR_ARCHITECTURE%"=="AMD64" ( + set busybox_exe=%kotlin_cli_target_dir%\bin\busybox64u.exe +) else ( + echo Unsupported architecture %PROCESSOR_ARCHITECTURE% >&2 + goto fail +) + +rem We use busybox here because it doesn't reinterpret the user-passed command-line arguments (that we pass via %*). +rem Also this way we can use the unified launcher script (.sh) +set KOTLIN_CLI_WRAPPER_PATH=%~f0 +"%busybox_exe%" sh "%kotlin_cli_target_dir%\bin\launcher.sh" %* +exit /B %ERRORLEVEL% diff --git a/newbuildsystem/module.yaml b/newbuildsystem/module.yaml new file mode 100644 index 0000000..0141b6f --- /dev/null +++ b/newbuildsystem/module.yaml @@ -0,0 +1,36 @@ +product: android/app + +settings: + android: + namespace: "org.bitcoindevkit.devkitwallet" + compileSdk: 36 + minSdk: 33 + compose: enabled + kotlin: + serialization: json + junit: junit-4 + +dependencies: + - androidx.core:core-ktx:1.17.0 + - androidx.datastore:datastore:1.2.0 + - com.google.protobuf:protobuf-javalite:4.33.5 + - org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0 + - androidx.core:core-splashscreen:1.2.0 + - androidx.activity:activity-compose:1.8.2 + + - bom: androidx.compose:compose-bom:2026.02.00 + - androidx.compose.animation:animation + - androidx.compose.ui:ui-tooling + - androidx.compose.ui:ui + - androidx.compose.material3:material3 + - androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0 + - androidx.navigation:navigation-compose:2.9.7 + + # Icons + - androidx.compose.material:material-icons-extended:1.7.8 + - com.composables:icons-lucide:1.1.0 + + # Bitcoin + - org.bitcoindevkit:bdk-android:3.0.0 + # QR Codes + - com.google.zxing:core:3.5.4 diff --git a/newbuildsystem/res/drawable/bdk_logo.xml b/newbuildsystem/res/drawable/bdk_logo.xml new file mode 100644 index 0000000..a28d65b --- /dev/null +++ b/newbuildsystem/res/drawable/bdk_logo.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/newbuildsystem/res/drawable/bdk_logo2.xml b/newbuildsystem/res/drawable/bdk_logo2.xml new file mode 100644 index 0000000..f8568bd --- /dev/null +++ b/newbuildsystem/res/drawable/bdk_logo2.xml @@ -0,0 +1,18 @@ + + + + diff --git a/newbuildsystem/res/drawable/ic_launcher_bdk_background.xml b/newbuildsystem/res/drawable/ic_launcher_bdk_background.xml new file mode 100644 index 0000000..668f371 --- /dev/null +++ b/newbuildsystem/res/drawable/ic_launcher_bdk_background.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/newbuildsystem/res/drawable/ic_launcher_bdk_foreground.xml b/newbuildsystem/res/drawable/ic_launcher_bdk_foreground.xml new file mode 100644 index 0000000..0591aa4 --- /dev/null +++ b/newbuildsystem/res/drawable/ic_launcher_bdk_foreground.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/newbuildsystem/res/drawable/ic_launcher_bdk_foreground_no_outline.xml b/newbuildsystem/res/drawable/ic_launcher_bdk_foreground_no_outline.xml new file mode 100644 index 0000000..0591aa4 --- /dev/null +++ b/newbuildsystem/res/drawable/ic_launcher_bdk_foreground_no_outline.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/newbuildsystem/res/font/google_sans_code_light.ttf b/newbuildsystem/res/font/google_sans_code_light.ttf new file mode 100644 index 0000000..714fb31 Binary files /dev/null and b/newbuildsystem/res/font/google_sans_code_light.ttf differ diff --git a/newbuildsystem/res/font/inter_bold.ttf b/newbuildsystem/res/font/inter_bold.ttf new file mode 100644 index 0000000..e974d96 Binary files /dev/null and b/newbuildsystem/res/font/inter_bold.ttf differ diff --git a/newbuildsystem/res/font/inter_medium.ttf b/newbuildsystem/res/font/inter_medium.ttf new file mode 100644 index 0000000..5c88739 Binary files /dev/null and b/newbuildsystem/res/font/inter_medium.ttf differ diff --git a/newbuildsystem/res/font/inter_regular.ttf b/newbuildsystem/res/font/inter_regular.ttf new file mode 100644 index 0000000..6b088a7 Binary files /dev/null and b/newbuildsystem/res/font/inter_regular.ttf differ diff --git a/newbuildsystem/res/font/inter_thin.ttf b/newbuildsystem/res/font/inter_thin.ttf new file mode 100644 index 0000000..3505b35 Binary files /dev/null and b/newbuildsystem/res/font/inter_thin.ttf differ diff --git a/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher.xml b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2f8431b --- /dev/null +++ b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk.xml b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk.xml new file mode 100644 index 0000000..af9e637 --- /dev/null +++ b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk_round.xml b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk_round.xml new file mode 100644 index 0000000..af9e637 --- /dev/null +++ b/newbuildsystem/res/mipmap-anydpi-v26/ic_launcher_bdk_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/newbuildsystem/res/mipmap-hdpi/ic_launcher.png b/newbuildsystem/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/newbuildsystem/res/mipmap-hdpi/ic_launcher.png differ diff --git a/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk.png b/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk.png new file mode 100644 index 0000000..b94c1ed Binary files /dev/null and b/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk.png differ diff --git a/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk_round.png b/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk_round.png new file mode 100644 index 0000000..07db04c Binary files /dev/null and b/newbuildsystem/res/mipmap-hdpi/ic_launcher_bdk_round.png differ diff --git a/newbuildsystem/res/mipmap-hdpi/ic_launcher_round.png b/newbuildsystem/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/newbuildsystem/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/newbuildsystem/res/mipmap-mdpi/ic_launcher.png b/newbuildsystem/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/newbuildsystem/res/mipmap-mdpi/ic_launcher.png differ diff --git a/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk.png b/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk.png new file mode 100644 index 0000000..d4fb078 Binary files /dev/null and b/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk.png differ diff --git a/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk_round.png b/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk_round.png new file mode 100644 index 0000000..efe9825 Binary files /dev/null and b/newbuildsystem/res/mipmap-mdpi/ic_launcher_bdk_round.png differ diff --git a/newbuildsystem/res/mipmap-mdpi/ic_launcher_round.png b/newbuildsystem/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/newbuildsystem/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/newbuildsystem/res/mipmap-xhdpi/ic_launcher.png b/newbuildsystem/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/newbuildsystem/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk.png b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk.png new file mode 100644 index 0000000..3bde8f4 Binary files /dev/null and b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk.png differ diff --git a/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk_round.png b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk_round.png new file mode 100644 index 0000000..5d4b7d9 Binary files /dev/null and b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_bdk_round.png differ diff --git a/newbuildsystem/res/mipmap-xhdpi/ic_launcher_round.png b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/newbuildsystem/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/newbuildsystem/res/mipmap-xxhdpi/ic_launcher.png b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk.png b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk.png new file mode 100644 index 0000000..66f997c Binary files /dev/null and b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk.png differ diff --git a/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk_round.png b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk_round.png new file mode 100644 index 0000000..ad3ace1 Binary files /dev/null and b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_bdk_round.png differ diff --git a/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_round.png b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/newbuildsystem/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher.png b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk.png b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk.png new file mode 100644 index 0000000..00ab356 Binary files /dev/null and b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk.png differ diff --git a/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk_round.png b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk_round.png new file mode 100644 index 0000000..28da01a Binary files /dev/null and b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_bdk_round.png differ diff --git a/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_round.png b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/newbuildsystem/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/newbuildsystem/res/values/colors.xml b/newbuildsystem/res/values/colors.xml new file mode 100644 index 0000000..f89c5e3 --- /dev/null +++ b/newbuildsystem/res/values/colors.xml @@ -0,0 +1,11 @@ + + + + + #1C1B1F + #E6E1E5 + + + + + diff --git a/newbuildsystem/res/values/splash.xml b/newbuildsystem/res/values/splash.xml new file mode 100644 index 0000000..c5adf6c --- /dev/null +++ b/newbuildsystem/res/values/splash.xml @@ -0,0 +1,8 @@ + + + + diff --git a/newbuildsystem/res/values/strings.xml b/newbuildsystem/res/values/strings.xml new file mode 100644 index 0000000..d0e88cc --- /dev/null +++ b/newbuildsystem/res/values/strings.xml @@ -0,0 +1,3 @@ + + Devkit Wallet + diff --git a/newbuildsystem/res/values/themes.xml b/newbuildsystem/res/values/themes.xml new file mode 100644 index 0000000..11e000d --- /dev/null +++ b/newbuildsystem/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/newbuildsystem/src/AndroidManifest.xml b/newbuildsystem/src/AndroidManifest.xml new file mode 100644 index 0000000..cbe7c27 --- /dev/null +++ b/newbuildsystem/src/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt new file mode 100644 index 0000000..9a73ceb --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/Kyoto.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.data + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import org.bitcoindevkit.CbfBuilder +import org.bitcoindevkit.CbfClient +import org.bitcoindevkit.CbfNode +import org.bitcoindevkit.Info +import org.bitcoindevkit.IpAddress +import org.bitcoindevkit.Network +import org.bitcoindevkit.Peer +import org.bitcoindevkit.ScanType +import org.bitcoindevkit.Transaction +import org.bitcoindevkit.Update +import org.bitcoindevkit.Wallet +import org.bitcoindevkit.Warning +import org.bitcoindevkit.Wtxid +import kotlin.collections.listOf + +private const val TAG = "KyotoClient" + +// TODO: Document this class well +class Kyoto private constructor( + private val kyotoNode: CbfNode, + private val kyotoClient: CbfClient, +) { + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) + + fun start(): Flow { + kyotoNode.run() + + return flow { + // Set this to stop under certain circumstances + while (true) { + val update = kyotoClient.update() + emit(update) + } + } + } + + fun infoLog(): SharedFlow { + val sharedFlow = MutableSharedFlow(replay = 0) + scope.launch { + while (true) { + val info = kyotoClient.nextInfo() + sharedFlow.emit(info) + } + } + return sharedFlow + } + + fun warningLog(): SharedFlow { + val sharedFlow = MutableSharedFlow(replay = 0) + scope.launch { + while (true) { + val warning = kyotoClient.nextWarning() + sharedFlow.emit(warning) + } + } + return sharedFlow + } + + fun logToLogcat() { + scope.launch { + infoLog().collect { + Log.i(TAG, it.toString()) + } + } + scope.launch { + warningLog().collect { + Log.i(TAG, it.toString()) + } + } + } + + fun lookupHost(hostname: String): List { + return kyotoClient.lookupHost(hostname) + } + + suspend fun broadcast(transaction: Transaction): Wtxid { + return kyotoClient.broadcast(transaction) + } + + fun connect(peer: Peer) { + kyotoClient.connect(peer) + } + + fun isRunning(): Boolean { + return kyotoClient.isRunning() + } + + fun shutdown() { + kyotoClient.shutdown() + } + + companion object { + private var instance: Kyoto? = null + + fun getInstance(): Kyoto = instance ?: throw KyotoNotInitialized() + + fun create(wallet: Wallet, dataDir: String, network: Network): Kyoto { + Log.i(TAG, "Starting Kyoto node") + val peers: List = when (network) { + Network.REGTEST -> { + val ip: IpAddress = IpAddress.fromIpv4(10u, 0u, 2u, 2u) + val peer1: Peer = Peer(ip, 18444u, false) + listOf(peer1) + } + + Network.SIGNET -> { + val ip: IpAddress = IpAddress.fromIpv4(68u, 47u, 229u, 218u) + val peer1: Peer = Peer(ip, null, false) + listOf(peer1) + } + + else -> { + listOf() + } + } + + val (client, node) = + CbfBuilder() + .dataDir(dataDir) + .peers(peers) + .connections(1u) + .scanType(ScanType.Sync) + .build(wallet) + + return Kyoto(node, client).also { instance = it } + } + } +} + +class KyotoNotInitialized : Exception() diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/TxDetails.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/TxDetails.kt new file mode 100644 index 0000000..15036eb --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/TxDetails.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.data + +import org.bitcoindevkit.FeeRate +import org.bitcoindevkit.Transaction + +data class TxDetails( + val transaction: Transaction, + val txid: String, + val sent: ULong, + val received: ULong, + val fee: ULong, + val feeRate: FeeRate?, + val pending: Boolean, + val confirmationBlock: ConfirmationBlock?, + val confirmationTimestamp: Timestamp?, +) + +@JvmInline +value class Timestamp(val timestamp: ULong) + +@JvmInline +value class ConfirmationBlock(val height: UInt) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/WalletConfigs.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/WalletConfigs.kt new file mode 100644 index 0000000..8158e41 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/WalletConfigs.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.data + +import org.bitcoindevkit.Descriptor +import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletScriptType + +data class NewWalletConfig( + val name: String, + val network: Network, + val scriptType: ActiveWalletScriptType, +) + +data class RecoverWalletConfig( + val name: String, + val network: Network, + val scriptType: ActiveWalletScriptType?, + val recoveryPhrase: String?, + val descriptor: Descriptor, + val changeDescriptor: Descriptor, +) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/AppSettings.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/AppSettings.kt new file mode 100644 index 0000000..9f62427 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/AppSettings.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.data.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream + +@Serializable +data class AppSettings( + val darkTheme: Boolean = true, + val introDone: Boolean = false, +) + +object AppSettingsSerializer : Serializer { + override val defaultValue = AppSettings() + + override suspend fun readFrom(input: InputStream): AppSettings { + try { + return Json.decodeFromString(input.readBytes().decodeToString()) + } catch (e: SerializationException) { + throw CorruptionException("Cannot read AppSettings.", e) + } + } + + override suspend fun writeTo(t: AppSettings, output: OutputStream) { + output.write(Json.encodeToString(t).encodeToByteArray()) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/WalletData.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/WalletData.kt new file mode 100644 index 0000000..760c811 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/data/datastore/WalletData.kt @@ -0,0 +1,57 @@ +package org.bitcoindevkit.devkitwallet.data.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream + +@Serializable +enum class ActiveWalletNetwork { + TESTNET, + SIGNET, + REGTEST, +} + +@Serializable +enum class ActiveWalletScriptType { + P2WPKH, + P2TR, + UNKNOWN, +} + +@Serializable +data class StoredWallet( + val id: String, + val name: String, + val network: ActiveWalletNetwork, + val scriptType: ActiveWalletScriptType, + val descriptor: String, + val changeDescriptor: String, + val recoveryPhrase: String = "", + val fullScanCompleted: Boolean = false, +) + +@Serializable +data class WalletData( + val wallets: List = emptyList(), + // network config fields go here alongside wallets +) + +object WalletDataSerializer : Serializer { + override val defaultValue = WalletData() + + override suspend fun readFrom(input: InputStream): WalletData { + try { + return Json.decodeFromString(input.readBytes().decodeToString()) + } catch (e: SerializationException) { + throw CorruptionException("Cannot read AppSettings.", e) + } + } + + override suspend fun writeTo(t: WalletData, output: OutputStream) { + output.write(Json.encodeToString(t).encodeToByteArray()) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/AppSettingsRepository.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/AppSettingsRepository.kt new file mode 100644 index 0000000..071708c --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/AppSettingsRepository.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.first +import org.bitcoindevkit.devkitwallet.data.datastore.AppSettings + +class AppSettingsRepository(private val store: DataStore) { + suspend fun fetchDarkTheme() = store.data.first().darkTheme + + suspend fun setDarkTheme(isDark: Boolean) = store.updateData { it.copy(darkTheme = isDark) } + + suspend fun fetchIntroDone() = store.data.first().introDone + + suspend fun setIntroDone() = store.updateData { it.copy(introDone = true) } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Constants.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Constants.kt new file mode 100644 index 0000000..0e7e305 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Constants.kt @@ -0,0 +1,2067 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import org.bitcoindevkit.Network + +val supportedNetworks: List = + listOf( + Network.SIGNET, + Network.TESTNET, + Network.REGTEST, + ) + +val bip39WordList: List = + listOf( + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse", + "achieve", + "acid", + "acoustic", + "acquire", + "across", + "act", + "action", + "actor", + "actress", + "actual", + "adapt", + "add", + "addict", + "address", + "adjust", + "admit", + "adult", + "advance", + "advice", + "aerobic", + "affair", + "afford", + "afraid", + "again", + "age", + "agent", + "agree", + "ahead", + "aim", + "air", + "airport", + "aisle", + "alarm", + "album", + "alcohol", + "alert", + "alien", + "all", + "alley", + "allow", + "almost", + "alone", + "alpha", + "already", + "also", + "alter", + "always", + "amateur", + "amazing", + "among", + "amount", + "amused", + "analyst", + "anchor", + "ancient", + "anger", + "angle", + "angry", + "animal", + "ankle", + "announce", + "annual", + "another", + "answer", + "antenna", + "antique", + "anxiety", + "any", + "apart", + "apology", + "appear", + "apple", + "approve", + "april", + "arch", + "arctic", + "area", + "arena", + "argue", + "arm", + "armed", + "armor", + "army", + "around", + "arrange", + "arrest", + "arrive", + "arrow", + "art", + "artefact", + "artist", + "artwork", + "ask", + "aspect", + "assault", + "asset", + "assist", + "assume", + "asthma", + "athlete", + "atom", + "attack", + "attend", + "attitude", + "attract", + "auction", + "audit", + "august", + "aunt", + "author", + "auto", + "autumn", + "average", + "avocado", + "avoid", + "awake", + "aware", + "away", + "awesome", + "awful", + "awkward", + "axis", + "baby", + "bachelor", + "bacon", + "badge", + "bag", + "balance", + "balcony", + "ball", + "bamboo", + "banana", + "banner", + "bar", + "barely", + "bargain", + "barrel", + "base", + "basic", + "basket", + "battle", + "beach", + "bean", + "beauty", + "because", + "become", + "beef", + "before", + "begin", + "behave", + "behind", + "believe", + "below", + "belt", + "bench", + "benefit", + "best", + "betray", + "better", + "between", + "beyond", + "bicycle", + "bid", + "bike", + "bind", + "biology", + "bird", + "birth", + "bitter", + "black", + "blade", + "blame", + "blanket", + "blast", + "bleak", + "bless", + "blind", + "blood", + "blossom", + "blouse", + "blue", + "blur", + "blush", + "board", + "boat", + "body", + "boil", + "bomb", + "bone", + "bonus", + "book", + "boost", + "border", + "boring", + "borrow", + "boss", + "bottom", + "bounce", + "box", + "boy", + "bracket", + "brain", + "brand", + "brass", + "brave", + "bread", + "breeze", + "brick", + "bridge", + "brief", + "bright", + "bring", + "brisk", + "broccoli", + "broken", + "bronze", + "broom", + "brother", + "brown", + "brush", + "bubble", + "buddy", + "budget", + "buffalo", + "build", + "bulb", + "bulk", + "bullet", + "bundle", + "bunker", + "burden", + "burger", + "burst", + "bus", + "business", + "busy", + "butter", + "buyer", + "buzz", + "cabbage", + "cabin", + "cable", + "cactus", + "cage", + "cake", + "call", + "calm", + "camera", + "camp", + "can", + "canal", + "cancel", + "candy", + "cannon", + "canoe", + "canvas", + "canyon", + "capable", + "capital", + "captain", + "car", + "carbon", + "card", + "cargo", + "carpet", + "carry", + "cart", + "case", + "cash", + "casino", + "castle", + "casual", + "cat", + "catalog", + "catch", + "category", + "cattle", + "caught", + "cause", + "caution", + "cave", + "ceiling", + "celery", + "cement", + "census", + "century", + "cereal", + "certain", + "chair", + "chalk", + "champion", + "change", + "chaos", + "chapter", + "charge", + "chase", + "chat", + "cheap", + "check", + "cheese", + "chef", + "cherry", + "chest", + "chicken", + "chief", + "child", + "chimney", + "choice", + "choose", + "chronic", + "chuckle", + "chunk", + "churn", + "cigar", + "cinnamon", + "circle", + "citizen", + "city", + "civil", + "claim", + "clap", + "clarify", + "claw", + "clay", + "clean", + "clerk", + "clever", + "click", + "client", + "cliff", + "climb", + "clinic", + "clip", + "clock", + "clog", + "close", + "cloth", + "cloud", + "clown", + "club", + "clump", + "cluster", + "clutch", + "coach", + "coast", + "coconut", + "code", + "coffee", + "coil", + "coin", + "collect", + "color", + "column", + "combine", + "come", + "comfort", + "comic", + "common", + "company", + "concert", + "conduct", + "confirm", + "congress", + "connect", + "consider", + "control", + "convince", + "cook", + "cool", + "copper", + "copy", + "coral", + "core", + "corn", + "correct", + "cost", + "cotton", + "couch", + "country", + "couple", + "course", + "cousin", + "cover", + "coyote", + "crack", + "cradle", + "craft", + "cram", + "crane", + "crash", + "crater", + "crawl", + "crazy", + "cream", + "credit", + "creek", + "crew", + "cricket", + "crime", + "crisp", + "critic", + "crop", + "cross", + "crouch", + "crowd", + "crucial", + "cruel", + "cruise", + "crumble", + "crunch", + "crush", + "cry", + "crystal", + "cube", + "culture", + "cup", + "cupboard", + "curious", + "current", + "curtain", + "curve", + "cushion", + "custom", + "cute", + "cycle", + "dad", + "damage", + "damp", + "dance", + "danger", + "daring", + "dash", + "daughter", + "dawn", + "day", + "deal", + "debate", + "debris", + "decade", + "december", + "decide", + "decline", + "decorate", + "decrease", + "deer", + "defense", + "define", + "defy", + "degree", + "delay", + "deliver", + "demand", + "demise", + "denial", + "dentist", + "deny", + "depart", + "depend", + "deposit", + "depth", + "deputy", + "derive", + "describe", + "desert", + "design", + "desk", + "despair", + "destroy", + "detail", + "detect", + "develop", + "device", + "devote", + "diagram", + "dial", + "diamond", + "diary", + "dice", + "diesel", + "diet", + "differ", + "digital", + "dignity", + "dilemma", + "dinner", + "dinosaur", + "direct", + "dirt", + "disagree", + "discover", + "disease", + "dish", + "dismiss", + "disorder", + "display", + "distance", + "divert", + "divide", + "divorce", + "dizzy", + "doctor", + "document", + "dog", + "doll", + "dolphin", + "domain", + "donate", + "donkey", + "donor", + "door", + "dose", + "double", + "dove", + "draft", + "dragon", + "drama", + "drastic", + "draw", + "dream", + "dress", + "drift", + "drill", + "drink", + "drip", + "drive", + "drop", + "drum", + "dry", + "duck", + "dumb", + "dune", + "during", + "dust", + "dutch", + "duty", + "dwarf", + "dynamic", + "eager", + "eagle", + "early", + "earn", + "earth", + "easily", + "east", + "easy", + "echo", + "ecology", + "economy", + "edge", + "edit", + "educate", + "effort", + "egg", + "eight", + "either", + "elbow", + "elder", + "electric", + "elegant", + "element", + "elephant", + "elevator", + "elite", + "else", + "embark", + "embody", + "embrace", + "emerge", + "emotion", + "employ", + "empower", + "empty", + "enable", + "enact", + "end", + "endless", + "endorse", + "enemy", + "energy", + "enforce", + "engage", + "engine", + "enhance", + "enjoy", + "enlist", + "enough", + "enrich", + "enroll", + "ensure", + "enter", + "entire", + "entry", + "envelope", + "episode", + "equal", + "equip", + "era", + "erase", + "erode", + "erosion", + "error", + "erupt", + "escape", + "essay", + "essence", + "estate", + "eternal", + "ethics", + "evidence", + "evil", + "evoke", + "evolve", + "exact", + "example", + "excess", + "exchange", + "excite", + "exclude", + "excuse", + "execute", + "exercise", + "exhaust", + "exhibit", + "exile", + "exist", + "exit", + "exotic", + "expand", + "expect", + "expire", + "explain", + "expose", + "express", + "extend", + "extra", + "eye", + "eyebrow", + "fabric", + "face", + "faculty", + "fade", + "faint", + "faith", + "fall", + "false", + "fame", + "family", + "famous", + "fan", + "fancy", + "fantasy", + "farm", + "fashion", + "fat", + "fatal", + "father", + "fatigue", + "fault", + "favorite", + "feature", + "february", + "federal", + "fee", + "feed", + "feel", + "female", + "fence", + "festival", + "fetch", + "fever", + "few", + "fiber", + "fiction", + "field", + "figure", + "file", + "film", + "filter", + "final", + "find", + "fine", + "finger", + "finish", + "fire", + "firm", + "first", + "fiscal", + "fish", + "fit", + "fitness", + "fix", + "flag", + "flame", + "flash", + "flat", + "flavor", + "flee", + "flight", + "flip", + "float", + "flock", + "floor", + "flower", + "fluid", + "flush", + "fly", + "foam", + "focus", + "fog", + "foil", + "fold", + "follow", + "food", + "foot", + "force", + "forest", + "forget", + "fork", + "fortune", + "forum", + "forward", + "fossil", + "foster", + "found", + "fox", + "fragile", + "frame", + "frequent", + "fresh", + "friend", + "fringe", + "frog", + "front", + "frost", + "frown", + "frozen", + "fruit", + "fuel", + "fun", + "funny", + "furnace", + "fury", + "future", + "gadget", + "gain", + "galaxy", + "gallery", + "game", + "gap", + "garage", + "garbage", + "garden", + "garlic", + "garment", + "gas", + "gasp", + "gate", + "gather", + "gauge", + "gaze", + "general", + "genius", + "genre", + "gentle", + "genuine", + "gesture", + "ghost", + "giant", + "gift", + "giggle", + "ginger", + "giraffe", + "girl", + "give", + "glad", + "glance", + "glare", + "glass", + "glide", + "glimpse", + "globe", + "gloom", + "glory", + "glove", + "glow", + "glue", + "goat", + "goddess", + "gold", + "good", + "goose", + "gorilla", + "gospel", + "gossip", + "govern", + "gown", + "grab", + "grace", + "grain", + "grant", + "grape", + "grass", + "gravity", + "great", + "green", + "grid", + "grief", + "grit", + "grocery", + "group", + "grow", + "grunt", + "guard", + "guess", + "guide", + "guilt", + "guitar", + "gun", + "gym", + "habit", + "hair", + "half", + "hammer", + "hamster", + "hand", + "happy", + "harbor", + "hard", + "harsh", + "harvest", + "hat", + "have", + "hawk", + "hazard", + "head", + "health", + "heart", + "heavy", + "hedgehog", + "height", + "hello", + "helmet", + "help", + "hen", + "hero", + "hidden", + "high", + "hill", + "hint", + "hip", + "hire", + "history", + "hobby", + "hockey", + "hold", + "hole", + "holiday", + "hollow", + "home", + "honey", + "hood", + "hope", + "horn", + "horror", + "horse", + "hospital", + "host", + "hotel", + "hour", + "hover", + "hub", + "huge", + "human", + "humble", + "humor", + "hundred", + "hungry", + "hunt", + "hurdle", + "hurry", + "hurt", + "husband", + "hybrid", + "ice", + "icon", + "idea", + "identify", + "idle", + "ignore", + "ill", + "illegal", + "illness", + "image", + "imitate", + "immense", + "immune", + "impact", + "impose", + "improve", + "impulse", + "inch", + "include", + "income", + "increase", + "index", + "indicate", + "indoor", + "industry", + "infant", + "inflict", + "inform", + "inhale", + "inherit", + "initial", + "inject", + "injury", + "inmate", + "inner", + "innocent", + "input", + "inquiry", + "insane", + "insect", + "inside", + "inspire", + "install", + "intact", + "interest", + "into", + "invest", + "invite", + "involve", + "iron", + "island", + "isolate", + "issue", + "item", + "ivory", + "jacket", + "jaguar", + "jar", + "jazz", + "jealous", + "jeans", + "jelly", + "jewel", + "job", + "join", + "joke", + "journey", + "joy", + "judge", + "juice", + "jump", + "jungle", + "junior", + "junk", + "just", + "kangaroo", + "keen", + "keep", + "ketchup", + "key", + "kick", + "kid", + "kidney", + "kind", + "kingdom", + "kiss", + "kit", + "kitchen", + "kite", + "kitten", + "kiwi", + "knee", + "knife", + "knock", + "know", + "lab", + "label", + "labor", + "ladder", + "lady", + "lake", + "lamp", + "language", + "laptop", + "large", + "later", + "latin", + "laugh", + "laundry", + "lava", + "law", + "lawn", + "lawsuit", + "layer", + "lazy", + "leader", + "leaf", + "learn", + "leave", + "lecture", + "left", + "leg", + "legal", + "legend", + "leisure", + "lemon", + "lend", + "length", + "lens", + "leopard", + "lesson", + "letter", + "level", + "liar", + "liberty", + "library", + "license", + "life", + "lift", + "light", + "like", + "limb", + "limit", + "link", + "lion", + "liquid", + "list", + "little", + "live", + "lizard", + "load", + "loan", + "lobster", + "local", + "lock", + "logic", + "lonely", + "long", + "loop", + "lottery", + "loud", + "lounge", + "love", + "loyal", + "lucky", + "luggage", + "lumber", + "lunar", + "lunch", + "luxury", + "lyrics", + "machine", + "mad", + "magic", + "magnet", + "maid", + "mail", + "main", + "major", + "make", + "mammal", + "man", + "manage", + "mandate", + "mango", + "mansion", + "manual", + "maple", + "marble", + "march", + "margin", + "marine", + "market", + "marriage", + "mask", + "mass", + "master", + "match", + "material", + "math", + "matrix", + "matter", + "maximum", + "maze", + "meadow", + "mean", + "measure", + "meat", + "mechanic", + "medal", + "media", + "melody", + "melt", + "member", + "memory", + "mention", + "menu", + "mercy", + "merge", + "merit", + "merry", + "mesh", + "message", + "metal", + "method", + "middle", + "midnight", + "milk", + "million", + "mimic", + "mind", + "minimum", + "minor", + "minute", + "miracle", + "mirror", + "misery", + "miss", + "mistake", + "mix", + "mixed", + "mixture", + "mobile", + "model", + "modify", + "mom", + "moment", + "monitor", + "monkey", + "monster", + "month", + "moon", + "moral", + "more", + "morning", + "mosquito", + "mother", + "motion", + "motor", + "mountain", + "mouse", + "move", + "movie", + "much", + "muffin", + "mule", + "multiply", + "muscle", + "museum", + "mushroom", + "music", + "must", + "mutual", + "myself", + "mystery", + "myth", + "naive", + "name", + "napkin", + "narrow", + "nasty", + "nation", + "nature", + "near", + "neck", + "need", + "negative", + "neglect", + "neither", + "nephew", + "nerve", + "nest", + "net", + "network", + "neutral", + "never", + "news", + "next", + "nice", + "night", + "noble", + "noise", + "nominee", + "noodle", + "normal", + "north", + "nose", + "notable", + "note", + "nothing", + "notice", + "novel", + "now", + "nuclear", + "number", + "nurse", + "nut", + "oak", + "obey", + "object", + "oblige", + "obscure", + "observe", + "obtain", + "obvious", + "occur", + "ocean", + "october", + "odor", + "off", + "offer", + "office", + "often", + "oil", + "okay", + "old", + "olive", + "olympic", + "omit", + "once", + "one", + "onion", + "online", + "only", + "open", + "opera", + "opinion", + "oppose", + "option", + "orange", + "orbit", + "orchard", + "order", + "ordinary", + "organ", + "orient", + "original", + "orphan", + "ostrich", + "other", + "outdoor", + "outer", + "output", + "outside", + "oval", + "oven", + "over", + "own", + "owner", + "oxygen", + "oyster", + "ozone", + "pact", + "paddle", + "page", + "pair", + "palace", + "palm", + "panda", + "panel", + "panic", + "panther", + "paper", + "parade", + "parent", + "park", + "parrot", + "party", + "pass", + "patch", + "path", + "patient", + "patrol", + "pattern", + "pause", + "pave", + "payment", + "peace", + "peanut", + "pear", + "peasant", + "pelican", + "pen", + "penalty", + "pencil", + "people", + "pepper", + "perfect", + "permit", + "person", + "pet", + "phone", + "photo", + "phrase", + "physical", + "piano", + "picnic", + "picture", + "piece", + "pig", + "pigeon", + "pill", + "pilot", + "pink", + "pioneer", + "pipe", + "pistol", + "pitch", + "pizza", + "place", + "planet", + "plastic", + "plate", + "play", + "please", + "pledge", + "pluck", + "plug", + "plunge", + "poem", + "poet", + "point", + "polar", + "pole", + "police", + "pond", + "pony", + "pool", + "popular", + "portion", + "position", + "possible", + "post", + "potato", + "pottery", + "poverty", + "powder", + "power", + "practice", + "praise", + "predict", + "prefer", + "prepare", + "present", + "pretty", + "prevent", + "price", + "pride", + "primary", + "print", + "priority", + "prison", + "private", + "prize", + "problem", + "process", + "produce", + "profit", + "program", + "project", + "promote", + "proof", + "property", + "prosper", + "protect", + "proud", + "provide", + "public", + "pudding", + "pull", + "pulp", + "pulse", + "pumpkin", + "punch", + "pupil", + "puppy", + "purchase", + "purity", + "purpose", + "purse", + "push", + "put", + "puzzle", + "pyramid", + "quality", + "quantum", + "quarter", + "question", + "quick", + "quit", + "quiz", + "quote", + "rabbit", + "raccoon", + "race", + "rack", + "radar", + "radio", + "rail", + "rain", + "raise", + "rally", + "ramp", + "ranch", + "random", + "range", + "rapid", + "rare", + "rate", + "rather", + "raven", + "raw", + "razor", + "ready", + "real", + "reason", + "rebel", + "rebuild", + "recall", + "receive", + "recipe", + "record", + "recycle", + "reduce", + "reflect", + "reform", + "refuse", + "region", + "regret", + "regular", + "reject", + "relax", + "release", + "relief", + "rely", + "remain", + "remember", + "remind", + "remove", + "render", + "renew", + "rent", + "reopen", + "repair", + "repeat", + "replace", + "report", + "require", + "rescue", + "resemble", + "resist", + "resource", + "response", + "result", + "retire", + "retreat", + "return", + "reunion", + "reveal", + "review", + "reward", + "rhythm", + "rib", + "ribbon", + "rice", + "rich", + "ride", + "ridge", + "rifle", + "right", + "rigid", + "ring", + "riot", + "ripple", + "risk", + "ritual", + "rival", + "river", + "road", + "roast", + "robot", + "robust", + "rocket", + "romance", + "roof", + "rookie", + "room", + "rose", + "rotate", + "rough", + "round", + "route", + "royal", + "rubber", + "rude", + "rug", + "rule", + "run", + "runway", + "rural", + "sad", + "saddle", + "sadness", + "safe", + "sail", + "salad", + "salmon", + "salon", + "salt", + "salute", + "same", + "sample", + "sand", + "satisfy", + "satoshi", + "sauce", + "sausage", + "save", + "say", + "scale", + "scan", + "scare", + "scatter", + "scene", + "scheme", + "school", + "science", + "scissors", + "scorpion", + "scout", + "scrap", + "screen", + "script", + "scrub", + "sea", + "search", + "season", + "seat", + "second", + "secret", + "section", + "security", + "seed", + "seek", + "segment", + "select", + "sell", + "seminar", + "senior", + "sense", + "sentence", + "series", + "service", + "session", + "settle", + "setup", + "seven", + "shadow", + "shaft", + "shallow", + "share", + "shed", + "shell", + "sheriff", + "shield", + "shift", + "shine", + "ship", + "shiver", + "shock", + "shoe", + "shoot", + "shop", + "short", + "shoulder", + "shove", + "shrimp", + "shrug", + "shuffle", + "shy", + "sibling", + "sick", + "side", + "siege", + "sight", + "sign", + "silent", + "silk", + "silly", + "silver", + "similar", + "simple", + "since", + "sing", + "siren", + "sister", + "situate", + "six", + "size", + "skate", + "sketch", + "ski", + "skill", + "skin", + "skirt", + "skull", + "slab", + "slam", + "sleep", + "slender", + "slice", + "slide", + "slight", + "slim", + "slogan", + "slot", + "slow", + "slush", + "small", + "smart", + "smile", + "smoke", + "smooth", + "snack", + "snake", + "snap", + "sniff", + "snow", + "soap", + "soccer", + "social", + "sock", + "soda", + "soft", + "solar", + "soldier", + "solid", + "solution", + "solve", + "someone", + "song", + "soon", + "sorry", + "sort", + "soul", + "sound", + "soup", + "source", + "south", + "space", + "spare", + "spatial", + "spawn", + "speak", + "special", + "speed", + "spell", + "spend", + "sphere", + "spice", + "spider", + "spike", + "spin", + "spirit", + "split", + "spoil", + "sponsor", + "spoon", + "sport", + "spot", + "spray", + "spread", + "spring", + "spy", + "square", + "squeeze", + "squirrel", + "stable", + "stadium", + "staff", + "stage", + "stairs", + "stamp", + "stand", + "start", + "state", + "stay", + "steak", + "steel", + "stem", + "step", + "stereo", + "stick", + "still", + "sting", + "stock", + "stomach", + "stone", + "stool", + "story", + "stove", + "strategy", + "street", + "strike", + "strong", + "struggle", + "student", + "stuff", + "stumble", + "style", + "subject", + "submit", + "subway", + "success", + "such", + "sudden", + "suffer", + "sugar", + "suggest", + "suit", + "summer", + "sun", + "sunny", + "sunset", + "super", + "supply", + "supreme", + "sure", + "surface", + "surge", + "surprise", + "surround", + "survey", + "suspect", + "sustain", + "swallow", + "swamp", + "swap", + "swarm", + "swear", + "sweet", + "swift", + "swim", + "swing", + "switch", + "sword", + "symbol", + "symptom", + "syrup", + "system", + "table", + "tackle", + "tag", + "tail", + "talent", + "talk", + "tank", + "tape", + "target", + "task", + "taste", + "tattoo", + "taxi", + "teach", + "team", + "tell", + "ten", + "tenant", + "tennis", + "tent", + "term", + "test", + "text", + "thank", + "that", + "theme", + "then", + "theory", + "there", + "they", + "thing", + "this", + "thought", + "three", + "thrive", + "throw", + "thumb", + "thunder", + "ticket", + "tide", + "tiger", + "tilt", + "timber", + "time", + "tiny", + "tip", + "tired", + "tissue", + "title", + "toast", + "tobacco", + "today", + "toddler", + "toe", + "together", + "toilet", + "token", + "tomato", + "tomorrow", + "tone", + "tongue", + "tonight", + "tool", + "tooth", + "top", + "topic", + "topple", + "torch", + "tornado", + "tortoise", + "toss", + "total", + "tourist", + "toward", + "tower", + "town", + "toy", + "track", + "trade", + "traffic", + "tragic", + "train", + "transfer", + "trap", + "trash", + "travel", + "tray", + "treat", + "tree", + "trend", + "trial", + "tribe", + "trick", + "trigger", + "trim", + "trip", + "trophy", + "trouble", + "truck", + "true", + "truly", + "trumpet", + "trust", + "truth", + "try", + "tube", + "tuition", + "tumble", + "tuna", + "tunnel", + "turkey", + "turn", + "turtle", + "twelve", + "twenty", + "twice", + "twin", + "twist", + "two", + "type", + "typical", + "ugly", + "umbrella", + "unable", + "unaware", + "uncle", + "uncover", + "under", + "undo", + "unfair", + "unfold", + "unhappy", + "uniform", + "unique", + "unit", + "universe", + "unknown", + "unlock", + "until", + "unusual", + "unveil", + "update", + "upgrade", + "uphold", + "upon", + "upper", + "upset", + "urban", + "urge", + "usage", + "use", + "used", + "useful", + "useless", + "usual", + "utility", + "vacant", + "vacuum", + "vague", + "valid", + "valley", + "valve", + "van", + "vanish", + "vapor", + "various", + "vast", + "vault", + "vehicle", + "velvet", + "vendor", + "venture", + "venue", + "verb", + "verify", + "version", + "very", + "vessel", + "veteran", + "viable", + "vibrant", + "vicious", + "victory", + "video", + "view", + "village", + "vintage", + "violin", + "virtual", + "virus", + "visa", + "visit", + "visual", + "vital", + "vivid", + "vocal", + "voice", + "void", + "volcano", + "volume", + "vote", + "voyage", + "wage", + "wagon", + "wait", + "walk", + "wall", + "walnut", + "want", + "warfare", + "warm", + "warrior", + "wash", + "wasp", + "waste", + "water", + "wave", + "way", + "wealth", + "weapon", + "wear", + "weasel", + "weather", + "web", + "wedding", + "weekend", + "weird", + "welcome", + "west", + "wet", + "whale", + "what", + "wheat", + "wheel", + "when", + "where", + "whip", + "whisper", + "wide", + "width", + "wife", + "wild", + "will", + "win", + "window", + "wine", + "wing", + "wink", + "winner", + "winter", + "wire", + "wisdom", + "wise", + "wish", + "witness", + "wolf", + "woman", + "wonder", + "wood", + "wool", + "word", + "work", + "world", + "worry", + "worth", + "wrap", + "wreck", + "wrestle", + "wrist", + "write", + "wrong", + "yard", + "year", + "yellow", + "you", + "young", + "youth", + "zebra", + "zero", + "zone", + "zoo", + ) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/CurrencyUnit.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/CurrencyUnit.kt new file mode 100644 index 0000000..e33b463 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/CurrencyUnit.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +enum class CurrencyUnit { + Bitcoin, + Satoshi, +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/DwLogger.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/DwLogger.kt new file mode 100644 index 0000000..293fff0 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/DwLogger.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +object DwLogger { + private const val MAX_LOGS = 5000 + private val logEntries = ArrayDeque(MAX_LOGS) + private val lock = Any() + + fun log(tag: LogLevel, message: String) { + synchronized(lock) { + if (logEntries.size >= MAX_LOGS) { + logEntries.removeLast() + } + val millis = System.currentTimeMillis() + val dateTime = + Instant + .ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .truncatedTo(ChronoUnit.SECONDS) + + logEntries.addFirst("$dateTime $tag $message") + } + } + + fun getLogs(): List { + synchronized(lock) { + return logEntries.toList() + } + } + + enum class LogLevel { + INFO, + WARN, + ERROR, + ; + + override fun toString(): String { + return when (this) { + INFO -> "[INFO] " + WARN -> "[WARN] " + ERROR -> "[ERROR]" + } + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Wallet.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Wallet.kt new file mode 100644 index 0000000..29fd725 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/Wallet.kt @@ -0,0 +1,367 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import android.util.Log +import kotlinx.coroutines.runBlocking +import org.bitcoindevkit.Address +import org.bitcoindevkit.AddressInfo +import org.bitcoindevkit.Amount +import org.bitcoindevkit.CanonicalTx +import org.bitcoindevkit.ChainPosition +import org.bitcoindevkit.Descriptor +import org.bitcoindevkit.DescriptorSecretKey +import org.bitcoindevkit.FeeRate +import org.bitcoindevkit.KeychainKind +import org.bitcoindevkit.Mnemonic +import org.bitcoindevkit.Network +import org.bitcoindevkit.NetworkKind +import org.bitcoindevkit.Persister +import org.bitcoindevkit.Psbt +import org.bitcoindevkit.Script +import org.bitcoindevkit.TxBuilder +import org.bitcoindevkit.Update +import org.bitcoindevkit.WordCount +import org.bitcoindevkit.devkitwallet.data.ConfirmationBlock +import org.bitcoindevkit.devkitwallet.data.NewWalletConfig +import org.bitcoindevkit.devkitwallet.data.RecoverWalletConfig +import org.bitcoindevkit.devkitwallet.data.Timestamp +import org.bitcoindevkit.devkitwallet.data.TxDetails +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletScriptType +import org.bitcoindevkit.devkitwallet.data.datastore.StoredWallet +import org.bitcoindevkit.devkitwallet.domain.utils.intoDomain +import org.bitcoindevkit.devkitwallet.domain.utils.intoProto +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.Recipient +import java.util.UUID +import org.bitcoindevkit.Wallet as BdkWallet + +private const val TAG = "Wallet" + +class Wallet private constructor( + val wallet: BdkWallet, + private val walletSecrets: WalletSecrets, + private val connection: Persister, + private var fullScanCompleted: Boolean, + private val walletId: String, + private val walletRepository: WalletRepository, + val internalAppFilesPath: String, + val network: Network, +) { + fun getWalletSecrets(): WalletSecrets { + return walletSecrets + } + + fun bestBlock(): UInt { + return wallet.latestCheckpoint().height + } + + fun createTransaction(recipientList: List, feeRate: FeeRate, opReturnMsg: String?): Psbt { + // technique 1 for adding a list of recipients to the TxBuilder + // var txBuilder = TxBuilder() + // for (recipient in recipientList) { + // txBuilder = txBuilder.addRecipient(address = recipient.first, amount = recipient.second) + // } + // txBuilder = txBuilder.feeRate(satPerVbyte = fee_rate) + + // technique 2 for adding a list of recipients to the TxBuilder + var txBuilder = + recipientList.fold(TxBuilder()) { builder, recipient -> + // val address = Address(recipient.address) + val scriptPubKey: Script = Address(recipient.address, this.network).scriptPubkey() + builder.addRecipient(scriptPubKey, Amount.fromSat(recipient.amount)) + } + // if (!opReturnMsg.isNullOrEmpty()) { + // txBuilder = txBuilder.addData(opReturnMsg.toByteArray(charset = Charsets.UTF_8).asUByteArray().toList()) + // } + return txBuilder.feeRate(feeRate).finish(wallet) + } + + // @OptIn(ExperimentalUnsignedTypes::class) + // fun createSendAllTransaction( + // recipient: String, + // feeRate: Float, + // enableRBF: Boolean, + // opReturnMsg: String? + // ): PartiallySignedTransaction { + // val scriptPubkey: Script = Address(recipient).scriptPubkey() + // var txBuilder = TxBuilder() + // .drainWallet() + // .drainTo(scriptPubkey) + // .feeRate(satPerVbyte = feeRate) + // + // if (enableRBF) { + // txBuilder = txBuilder.enableRbf() + // } + // if (!opReturnMsg.isNullOrEmpty()) { + // txBuilder = txBuilder.addData(opReturnMsg.toByteArray(charset = Charsets.UTF_8).asUByteArray().toList()) + // } + // return txBuilder.finish(wallet).psbt + // } + + // fun createBumpFeeTransaction(txid: String, feeRate: Float): PartiallySignedTransaction { + // return BumpFeeTxBuilder(txid = txid, newFeeRate = feeRate) + // .enableRbf() + // .finish(wallet = wallet) + // } + + fun sign(psbt: Psbt): Boolean { + return wallet.sign(psbt) + } + + // fun broadcast(signedPsbt: Psbt): String { + // currentBlockchainClient?.broadcast(signedPsbt.extractTx()) + // ?: throw IllegalStateException("Blockchain client not initialized") + // return signedPsbt.extractTx().computeTxid().toString() + // } + + private fun getAllTransactions(): List = wallet.transactions() + + fun getAllTxDetails(): List { + val transactions = getAllTransactions() + return transactions.map { tx -> + val txid = tx.transaction.computeTxid() + val (sent, received) = wallet.sentAndReceived(tx.transaction) + var feeRate: FeeRate? = null + var fee: Amount? = null + // TODO: I don't know why we're getting negative fees here, but it looks like a bug + try { + fee = wallet.calculateFee(tx.transaction) + } catch (e: Exception) { + Log.e(TAG, "Error calculating fee rate for tx $txid: $e") + } + try { + feeRate = wallet.calculateFeeRate(tx.transaction) + } catch (e: Exception) { + Log.e(TAG, "Error calculating fee for tx $txid: $e") + } + + val (confirmationBlock, confirmationTimestamp, pending) = + when (val position = tx.chainPosition) { + is ChainPosition.Unconfirmed -> { + Triple(null, null, true) + } + + is ChainPosition.Confirmed -> { + Triple( + ConfirmationBlock(position.confirmationBlockTime.blockId.height), + Timestamp(position.confirmationBlockTime.confirmationTime), + false, + ) + } + } + TxDetails( + tx.transaction, + txid.toString(), + sent.toSat(), + received.toSat(), + fee?.toSat() ?: 0uL, + feeRate, + pending, + confirmationBlock, + confirmationTimestamp + ) + } + } + + // fun getTransaction(txid: String): TransactionDetails? { + // val allTransactions = getAllTransactions() + // allTransactions.forEach { + // if (it.txid == txid) { + // return it + // } + // } + // return null + // } + + fun getBalance(): ULong = wallet.balance().total.toSat() + + fun getNewAddress(): AddressInfo = wallet.revealNextAddress(KeychainKind.EXTERNAL) + + fun applyUpdate(update: Update) { + wallet.applyUpdate(update) + wallet.persist(connection) + Log.i("KYOTOTEST", "Wallet applied a Kyoto update") + } + + companion object { + fun createWallet( + newWalletConfig: NewWalletConfig, + internalAppFilesPath: String, + walletRepository: WalletRepository, + ): Wallet { + val mnemonic = Mnemonic(WordCount.WORDS12) + val bip32ExtendedRootKey = DescriptorSecretKey(NetworkKind.TEST, mnemonic, null) + val descriptor: Descriptor = + createScriptAppropriateDescriptor( + newWalletConfig.scriptType, + bip32ExtendedRootKey, + KeychainKind.EXTERNAL, + ) + val changeDescriptor: Descriptor = + createScriptAppropriateDescriptor( + newWalletConfig.scriptType, + bip32ExtendedRootKey, + KeychainKind.INTERNAL, + ) + val walletId = UUID.randomUUID().toString() + val connection = Persister.newSqlite("$internalAppFilesPath/wallet-${walletId.take(8)}.sqlite3") + + // Create SingleWallet object for saving to datastore + val newWalletForDatastore: StoredWallet = + StoredWallet( + id = walletId, + name = newWalletConfig.name, + network = newWalletConfig.network.intoProto(), + scriptType = ActiveWalletScriptType.P2WPKH, + descriptor = descriptor.toStringWithSecret(), + changeDescriptor = changeDescriptor.toStringWithSecret(), + recoveryPhrase = mnemonic.toString() + ) + // TODO: launch this correctly, not on the main thread + // Save the new wallet to the datastore + runBlocking { walletRepository.addWallet(newWalletForDatastore) } + + val bdkWallet = + BdkWallet( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + network = newWalletConfig.network, + persister = connection, + ) + + val walletSecrets = WalletSecrets(descriptor, changeDescriptor, mnemonic.toString()) + + return Wallet( + wallet = bdkWallet, + walletSecrets = walletSecrets, + connection = connection, + fullScanCompleted = false, + walletId = walletId, + walletRepository = walletRepository, + internalAppFilesPath = internalAppFilesPath, + network = newWalletConfig.network + ) + } + + fun loadActiveWallet( + activeWallet: StoredWallet, + internalAppFilesPath: String, + walletRepository: WalletRepository, + ): Wallet { + val descriptor = Descriptor(activeWallet.descriptor, NetworkKind.TEST) + val changeDescriptor = Descriptor(activeWallet.changeDescriptor, NetworkKind.TEST) + val connection = Persister.newSqlite("$internalAppFilesPath/wallet-${activeWallet.id.take(8)}.sqlite3") + val bdkWallet = + BdkWallet.load( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = connection, + ) + + val walletSecrets = WalletSecrets(descriptor, changeDescriptor, activeWallet.recoveryPhrase) + return Wallet( + wallet = bdkWallet, + walletSecrets = walletSecrets, + connection = connection, + fullScanCompleted = activeWallet.fullScanCompleted, + walletId = activeWallet.id, + walletRepository = walletRepository, + internalAppFilesPath = internalAppFilesPath, + network = activeWallet.network.intoDomain() + ) + } + + fun recoverWallet( + recoverWalletConfig: RecoverWalletConfig, + internalAppFilesPath: String, + walletRepository: WalletRepository, + ): Wallet { + Log.i(TAG, "Recovering wallet with config: $recoverWalletConfig") + var descriptor: Descriptor? = null + var changeDescriptor: Descriptor? = null + var mnemonicString: String = "" + + // If there is a recovery phrase, we use it to recover the wallet + if (recoverWalletConfig.recoveryPhrase != null && recoverWalletConfig.scriptType != null) { + val mnemonic: Mnemonic = Mnemonic.fromString(recoverWalletConfig.recoveryPhrase) + mnemonicString = mnemonic.toString() + val bip32ExtendedRootKey = DescriptorSecretKey(NetworkKind.TEST, mnemonic, null) + descriptor = + createScriptAppropriateDescriptor( + recoverWalletConfig.scriptType, + bip32ExtendedRootKey, + KeychainKind.EXTERNAL, + ) + changeDescriptor = + createScriptAppropriateDescriptor( + recoverWalletConfig.scriptType, + bip32ExtendedRootKey, + KeychainKind.INTERNAL, + ) + } else { + descriptor = recoverWalletConfig.descriptor + changeDescriptor = recoverWalletConfig.changeDescriptor + } + val walletId = UUID.randomUUID().toString() + val connection = Persister.newSqlite("$internalAppFilesPath/wallet-${walletId.take(8)}.sqlite3") + + // Create SingleWallet object for saving to datastore + val newWalletForDatastore: StoredWallet = + StoredWallet( + id = walletId, + name = recoverWalletConfig.name, + network = recoverWalletConfig.network.intoProto(), + scriptType = ActiveWalletScriptType.P2WPKH, + descriptor = descriptor.toStringWithSecret(), + changeDescriptor = changeDescriptor.toStringWithSecret(), + recoveryPhrase = mnemonicString + ) + + // TODO: launch this correctly, not on the main thread + // Save the new wallet to the datastore + runBlocking { walletRepository.addWallet(newWalletForDatastore) } + + val bdkWallet = + BdkWallet( + descriptor = descriptor, + changeDescriptor = changeDescriptor, + persister = connection, + network = recoverWalletConfig.network, + ) + + val walletSecrets = WalletSecrets(descriptor, changeDescriptor, mnemonicString) + return Wallet( + wallet = bdkWallet, + walletSecrets = walletSecrets, + connection = connection, + fullScanCompleted = false, + walletId = walletId, + walletRepository = walletRepository, + internalAppFilesPath = internalAppFilesPath, + network = recoverWalletConfig.network + ) + } + } +} + +fun createScriptAppropriateDescriptor( + scriptType: ActiveWalletScriptType, + bip32ExtendedRootKey: DescriptorSecretKey, + keychain: KeychainKind, +): Descriptor { + return when (scriptType) { + ActiveWalletScriptType.P2WPKH -> Descriptor.newBip84(bip32ExtendedRootKey, keychain, NetworkKind.TEST) + ActiveWalletScriptType.P2TR -> Descriptor.newBip86(bip32ExtendedRootKey, keychain, NetworkKind.TEST) + ActiveWalletScriptType.UNKNOWN -> TODO() + // ActiveWalletScriptType.UNRECOGNIZED -> TODO() + } +} + +data class WalletSecrets( + val descriptor: Descriptor, + val changeDescriptor: Descriptor, + val recoveryPhrase: String, +) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/WalletRepository.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/WalletRepository.kt new file mode 100644 index 0000000..8c9e204 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/WalletRepository.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.first +import org.bitcoindevkit.devkitwallet.data.datastore.StoredWallet +import org.bitcoindevkit.devkitwallet.data.datastore.WalletData + +class WalletRepository(private val store: DataStore) { + suspend fun fetchWallets() = store.data.first().wallets + + suspend fun addWallet(wallet: StoredWallet) = store.updateData { it.copy(wallets = it.wallets + wallet) } + + suspend fun setFullScanCompleted(walletId: String) = + store.updateData { data -> + data.copy( + wallets = data.wallets.map { + if (it.id == walletId) it.copy(fullScanCompleted = true) else it + } + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/FormatInBtc.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/FormatInBtc.kt new file mode 100644 index 0000000..e8b5974 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/FormatInBtc.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain.utils + +import java.text.DecimalFormat + +fun ULong?.formatInBtc(): String { + val balanceInSats = + if (this == 0UL || this == null) { + 0F + } else { + this.toDouble().div(100_000_000) + } + return DecimalFormat("0.00000000").format(balanceInSats) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/ProtobufExtensions.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/ProtobufExtensions.kt new file mode 100644 index 0000000..d8d505b --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/ProtobufExtensions.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain.utils + +import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletNetwork + +fun Network.intoProto(): ActiveWalletNetwork { + return when (this) { + Network.TESTNET -> ActiveWalletNetwork.TESTNET + Network.TESTNET4 -> throw IllegalArgumentException("Bitcoin testnet 4 network is not supported") + Network.SIGNET -> ActiveWalletNetwork.SIGNET + Network.REGTEST -> ActiveWalletNetwork.REGTEST + Network.BITCOIN -> throw IllegalArgumentException("Bitcoin mainnet network is not supported") + } +} + +fun ActiveWalletNetwork.intoDomain(): Network { + return when (this) { + ActiveWalletNetwork.TESTNET -> Network.TESTNET + ActiveWalletNetwork.SIGNET -> Network.SIGNET + ActiveWalletNetwork.REGTEST -> Network.REGTEST + // ActiveWalletNetwork.UNRECOGNIZED -> throw IllegalArgumentException("Unrecognized network") + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/Timestamps.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/Timestamps.kt new file mode 100644 index 0000000..efb850d --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/domain/utils/Timestamps.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.domain.utils + +import android.text.format.DateFormat +import java.util.Calendar +import java.util.Locale + +// extension function on the ULong timestamp provided in the Transaction.Confirmed type +fun ULong.timestampToString(): String { + val calendar = Calendar.getInstance(Locale.ENGLISH) + calendar.timeInMillis = (this * 1000u).toLong() + return DateFormat.format("MMMM d yyyy HH:mm", calendar).toString() +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/DevkitWalletActivity.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/DevkitWalletActivity.kt new file mode 100644 index 0000000..b064ded --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/DevkitWalletActivity.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.graphics.drawable.toDrawable +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.data.NewWalletConfig +import org.bitcoindevkit.devkitwallet.data.RecoverWalletConfig +import org.bitcoindevkit.devkitwallet.data.datastore.AppSettings +import org.bitcoindevkit.devkitwallet.data.datastore.AppSettingsSerializer +import org.bitcoindevkit.devkitwallet.data.datastore.StoredWallet +import org.bitcoindevkit.devkitwallet.data.datastore.WalletData +import org.bitcoindevkit.devkitwallet.data.datastore.WalletDataSerializer +import org.bitcoindevkit.devkitwallet.domain.AppSettingsRepository +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.domain.WalletRepository +import org.bitcoindevkit.devkitwallet.presentation.navigation.AppNavigation +import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitTheme +import org.bitcoindevkit.devkitwallet.presentation.theme.themeSurfaceColor +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.OnboardingScreen + +private const val TAG = "DevkitWalletActivity" +private val Context.appSettingsStore: DataStore by dataStore( + fileName = "app_settings.json", + serializer = AppSettingsSerializer, +) +private val Context.walletDataStore: DataStore by dataStore( + fileName = "wallet_data.json", + serializer = WalletDataSerializer, +) + +class DevkitWalletActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + + super.onCreate(savedInstanceState) + + // Initialize Devkit Wallet Logger (used in the LogsScreen) + DwLogger.log(INFO, "Devkit Wallet app started") + + val appSettingsRepository = AppSettingsRepository(appSettingsStore) + val walletRepository = WalletRepository(walletDataStore) + + var activeWallet: Wallet? by mutableStateOf(null) + var activeWallets: List by mutableStateOf(emptyList()) + var onboardingDone: Boolean by mutableStateOf(false) + var useDarkTheme: Boolean by mutableStateOf(true) + var preferencesLoaded: Boolean by mutableStateOf(false) + + val onBuildWalletButtonClicked: (WalletCreateType) -> Unit = { walletCreateType -> + try { + activeWallet = + when (walletCreateType) { + is WalletCreateType.FROMSCRATCH -> { + Wallet.createWallet( + newWalletConfig = walletCreateType.newWalletConfig, + internalAppFilesPath = filesDir.absolutePath, + walletRepository = walletRepository, + ) + } + + is WalletCreateType.LOADEXISTING -> { + Wallet.loadActiveWallet( + activeWallet = walletCreateType.activeWallet, + internalAppFilesPath = filesDir.absolutePath, + walletRepository = walletRepository, + ) + } + + is WalletCreateType.RECOVER -> { + Wallet.recoverWallet( + recoverWalletConfig = walletCreateType.recoverWalletConfig, + internalAppFilesPath = filesDir.absolutePath, + walletRepository = walletRepository, + ) + } + } + } catch (e: Throwable) { + Log.i(TAG, "Could not build wallet: $e") + } + } + + val onToggleTheme: () -> Unit = { + useDarkTheme = !useDarkTheme + // Keep the window background in sync with the Compose theme. Navigation transitions + // include a fade-out, which causes the window background to show through briefly. + // Updating it here (synchronously, before Compose recomposes) prevents a color flash. + window.setBackgroundDrawable(themeSurfaceColor(useDarkTheme).toDrawable()) + lifecycleScope.launch { appSettingsRepository.setDarkTheme(useDarkTheme) } + } + + lifecycleScope.launch { + activeWallets = + async { + walletRepository.fetchWallets() + }.await() + + onboardingDone = + async { + appSettingsRepository.fetchIntroDone() + }.await() + + useDarkTheme = + async { + appSettingsRepository.fetchDarkTheme() + }.await() + + // Set the window background before allowing the UI to render for the first time, + // so the correct surface color is already in place when Compose draws its first frame. + window.setBackgroundDrawable(ColorDrawable(themeSurfaceColor(useDarkTheme))) + preferencesLoaded = true + } + + val onFinishOnboarding: () -> Unit = { + lifecycleScope.launch { appSettingsRepository.setIntroDone() } + onboardingDone = true + } + + setContent { + if (!preferencesLoaded) return@setContent + + if (!onboardingDone) { + DwLogger.log(INFO, "First time opening the app, triggering onboarding screen") + OnboardingScreen(onFinishOnboarding) + } else { + DevkitTheme(darkTheme = useDarkTheme) { + AppNavigation( + activeWallet = activeWallet, + activeWallets = activeWallets, + onBuildWalletButtonClicked = onBuildWalletButtonClicked, + useDarkTheme = useDarkTheme, + onToggleTheme = onToggleTheme, + ) + } + } + } + } +} + +sealed class WalletCreateType { + data class FROMSCRATCH(val newWalletConfig: NewWalletConfig) : WalletCreateType() + + data class LOADEXISTING(val activeWallet: StoredWallet) : WalletCreateType() + + data class RECOVER(val recoverWalletConfig: RecoverWalletConfig) : WalletCreateType() +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt new file mode 100644 index 0000000..48b1364 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/AppNavigation.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import org.bitcoindevkit.devkitwallet.data.datastore.StoredWallet +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.ActiveWalletsScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.CreateNewWalletScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.RecoverWalletScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.WalletChoiceScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.AboutScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.BlockchainClientScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.LogsScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.RecoveryDataScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.SettingsScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings.ThemeScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.RBFScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.ReceiveScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.SendScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.TransactionHistoryScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.TransactionScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.WalletHomeScreen +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.AddressViewModel +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.SendViewModel +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.WalletViewModel + +// M3 motion easing curves +private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +private const val ENTER_DURATION = 400 +private const val EXIT_DURATION = 200 +private const val FADE_IN_DURATION = 300 +private const val FADE_OUT_DURATION = 150 +private const val SLIDE_DISTANCE_DP = 30 + +// Forward: entering screen slides in from right and fades in (decelerate) +private val m3ForwardEnter: EnterTransition = + slideInHorizontally( + animationSpec = tween(ENTER_DURATION, easing = EmphasizedDecelerate), + initialOffsetX = { SLIDE_DISTANCE_DP * 3 }, + ) + fadeIn( + animationSpec = tween(FADE_IN_DURATION, delayMillis = 50, easing = EmphasizedDecelerate), + ) + +// Forward: outgoing screen slides out to left and fades out (accelerate) +private val m3ForwardExit: ExitTransition = + slideOutHorizontally( + animationSpec = tween(EXIT_DURATION, easing = EmphasizedAccelerate), + targetOffsetX = { -SLIDE_DISTANCE_DP * 3 }, + ) + fadeOut( + animationSpec = tween(FADE_OUT_DURATION, easing = EmphasizedAccelerate), + ) + +// Backward: returning screen slides in from left and fades in (decelerate) +private val m3BackwardEnter: EnterTransition = + slideInHorizontally( + animationSpec = tween(ENTER_DURATION, easing = EmphasizedDecelerate), + initialOffsetX = { -SLIDE_DISTANCE_DP * 3 }, + ) + fadeIn( + animationSpec = tween(FADE_IN_DURATION, delayMillis = 50, easing = EmphasizedDecelerate), + ) + +// Backward: outgoing screen slides out to right and fades out (accelerate) +private val m3BackwardExit: ExitTransition = + slideOutHorizontally( + animationSpec = tween(EXIT_DURATION, easing = EmphasizedAccelerate), + targetOffsetX = { SLIDE_DISTANCE_DP * 3 }, + ) + fadeOut( + animationSpec = tween(FADE_OUT_DURATION, easing = EmphasizedAccelerate), + ) + +@Composable +fun AppNavigation( + activeWallet: Wallet?, + activeWallets: List, + onBuildWalletButtonClicked: (WalletCreateType) -> Unit, + useDarkTheme: Boolean, + onToggleTheme: () -> Unit, +) { + val navController: NavHostController = rememberNavController() + + val walletViewModel = remember(activeWallet) { activeWallet?.let { WalletViewModel(it) } } + val addressViewModel = remember(activeWallet) { activeWallet?.let { AddressViewModel(it) } } + val sendViewModel = remember(activeWallet) { activeWallet?.let { SendViewModel(it) } } + + LaunchedEffect(activeWallet) { + if (activeWallet != null) { + navController.navigate(HomeScreen) { + popUpTo(WalletChoiceScreen) { inclusive = true } + } + } + } + + NavHost( + navController = navController, + startDestination = WalletChoiceScreen, + enterTransition = { m3ForwardEnter }, + exitTransition = { m3ForwardExit }, + popEnterTransition = { m3BackwardEnter }, + popExitTransition = { m3BackwardExit }, + ) { + // Create-wallet flow destinations + composable { + WalletChoiceScreen(navController = navController) + } + + composable { + ActiveWalletsScreen( + activeWallets = activeWallets, + navController = navController, + onBuildWalletButtonClicked + ) + } + + composable { + CreateNewWalletScreen(navController = navController, onBuildWalletButtonClicked) + } + + composable { + RecoverWalletScreen(onAction = onBuildWalletButtonClicked, navController = navController) + } + + // Wallet screens + composable { + WalletHomeScreen( + state = walletViewModel!!.state, + onAction = walletViewModel::onAction, + navController = navController, + ) + } + + composable { + ReceiveScreen( + state = addressViewModel!!.state, + onAction = addressViewModel::onAction, + navController = navController, + ) + } + + composable { SendScreen(navController, sendViewModel!!) } + + composable { + val args = it.toRoute() + RBFScreen(args.txid, navController) + } + + composable { TransactionHistoryScreen(navController, activeWallet!!) } + + composable { + val args = it.toRoute() + TransactionScreen(args.txid, navController) + } + + // Settings/drawer screens + composable { SettingsScreen(navController = navController) } + + composable { AboutScreen(navController = navController) } + + composable { + RecoveryDataScreen(activeWallet!!.getWalletSecrets(), navController = navController) + } + + composable { + BlockchainClientScreen( + state = walletViewModel!!.state, + onAction = walletViewModel::onAction, + navController = navController, + ) + } + + composable { LogsScreen(navController = navController) } + + composable { + ThemeScreen( + useDarkTheme = useDarkTheme, + onToggleTheme = onToggleTheme, + navController = navController, + ) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/Destinations.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/Destinations.kt new file mode 100644 index 0000000..917f863 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/navigation/Destinations.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.navigation + +import kotlinx.serialization.Serializable + +// Create wallet navigation destinations +@Serializable +object WalletChoiceScreen + +@Serializable +object ActiveWalletsScreen + +@Serializable +object CreateNewWalletScreen + +@Serializable +object WalletRecoveryScreen + +// Home navigation destinations +@Serializable +object SettingsScreen + +@Serializable +object AboutScreen + +@Serializable +object RecoveryPhraseScreen + +@Serializable +object BlockchainClientScreen + +@Serializable +object LogsScreen + +@Serializable +object ThemeScreen + +// Wallet navigation destinations +@Serializable +object HomeScreen + +@Serializable +object ReceiveScreen + +@Serializable +object SendScreen + +@Serializable +object TransactionHistoryScreen + +@Serializable +data class TransactionScreen(val txid: String) + +@Serializable +data class RbfScreen(val txid: String) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/DevkitWalletColors.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/DevkitWalletColors.kt new file mode 100644 index 0000000..d1662b4 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/DevkitWalletColors.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.theme + +import androidx.compose.ui.graphics.Color + +// NightGlow (dark theme) accent colors +val NightGlowHistoryAccent: Color = Color(0xFFE3D082) +val NightGlowSubtle: Color = Color(0xFF79747E) + +// DayGlow (light theme) accent colors +val DayGlowHistoryAccent: Color = Color(0xFF816C2A) +val DayGlowSubtle: Color = Color(0xFF79747E) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Fonts.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Fonts.kt new file mode 100644 index 0000000..c1843ba --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Fonts.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.theme + +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import org.bitcoindevkit.devkitwallet.R + +// val monoRegular = +// FontFamily( +// Font( +// resId = R.font.ia_writer_mono_regular, +// weight = FontWeight.Normal, +// style = FontStyle.Normal, +// ), +// ) +// +// val monoBold = +// FontFamily( +// Font( +// resId = R.font.ia_writer_mono_bold, +// weight = FontWeight.Bold, +// style = FontStyle.Normal, +// ), +// ) + +val googleSansCode = + FontFamily( + Font( + resId = R.font.google_sans_code_light, + weight = FontWeight.Light, + style = FontStyle.Normal, + ), + ) + +val inter = + FontFamily( + Font( + resId = R.font.inter_thin, + weight = FontWeight.Thin, + style = FontStyle.Normal, + ), + Font( + resId = R.font.inter_regular, + weight = FontWeight.Normal, + style = FontStyle.Normal, + ), + Font( + resId = R.font.inter_medium, + weight = FontWeight.Medium, + style = FontStyle.Normal, + ), + Font( + resId = R.font.inter_bold, + weight = FontWeight.Bold, + style = FontStyle.Normal, + ), + ) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Theme.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Theme.kt new file mode 100644 index 0000000..f2e9f60 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Theme.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val NightGlowColorScheme = darkColorScheme( + surface = Color(0xFF1C1B1F), + onSurface = Color(0xFFE6E1E5), + surfaceVariant = Color(0xFF49454F), + onSurfaceVariant = Color(0xFFCAC4D0), + background = Color(0xFF1C1B1F), + onBackground = Color(0xFFE6E1E5), + outline = Color(0xFFCAC4D0), + outlineVariant = Color(0xFF49454F), + primary = Color(0xFFF2D2B6), + onPrimary = Color(0xFF1C1B1F), + secondary = Color(0xFFC6B2E0), + onSecondary = Color(0xFF1C1B1F), + tertiary = Color(0xFF8FD998), + onTertiary = Color(0xFF1C1B1F), +) + +private val DayGlowColorScheme = lightColorScheme( + surface = Color(0xFFFFF8F4), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFEDE5DF), + onSurfaceVariant = Color(0xFF49454F), + background = Color(0xFFFFF8F4), + onBackground = Color(0xFF1C1B1F), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), + primary = Color(0xFF7D5260), + onPrimary = Color(0xFFFFFFFF), + secondary = Color(0xFF625B71), + onSecondary = Color(0xFFFFFFFF), + tertiary = Color(0xFF2E6A3C), + onTertiary = Color(0xFFFFFFFF), +) + +// Returns the surface color for the given theme as an ARGB int for use outside of Compose (e.g. +// setting the window background from the Activity). Keeps the values co-located with the color +// schemes above so there is a single source of truth for each theme's surface color. +fun themeSurfaceColor(darkTheme: Boolean): Int = if (darkTheme) 0xFF1C1B1F.toInt() else 0xFFFFF8F4.toInt() + +@Composable +fun DevkitTheme(darkTheme: Boolean = true, content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (darkTheme) NightGlowColorScheme else DayGlowColorScheme, + typography = devkitTypography, + content = content, + ) +} + +// NOTES ON THE UI +// - The standard padding is 32dp for start/end, 16dp for top/bottom diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Type.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Type.kt new file mode 100644 index 0000000..b18f25e --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/theme/Type.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +internal val devkitTypography = + Typography( + labelLarge = + TextStyle( + fontFamily = inter, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 28.sp, + ), + ) diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/CustomSnackbar.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/CustomSnackbar.kt new file mode 100644 index 0000000..8f98067 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/CustomSnackbar.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.X +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +@Composable +fun CustomSnackbar(data: SnackbarData) { + val colorScheme = MaterialTheme.colorScheme + Snackbar( + modifier = Modifier.padding(12.dp), + action = { + IconButton( + onClick = { data.performAction() }, + ) { + Icon( + imageVector = Lucide.X, + contentDescription = "Ok", + tint = colorScheme.onSurface, + ) + } + }, + containerColor = colorScheme.surfaceVariant, + ) { + Text( + text = data.visuals.message, + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/LoadingAnimation.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/LoadingAnimation.kt new file mode 100644 index 0000000..50fc018 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/LoadingAnimation.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +fun LoadingAnimation( + circleColor: Color = Color(0xffE9C46A), + circleSize: Dp = 21.dp, + animationDelay: Int = 800, + initialAlpha: Float = 0.3f, +) { + val circles = + listOf( + remember { Animatable(initialValue = initialAlpha) }, + remember { Animatable(initialValue = initialAlpha) }, + remember { Animatable(initialValue = initialAlpha) }, + ) + + circles.forEachIndexed { index, animatable -> + LaunchedEffect(Unit) { + // Use coroutine delay to sync animations + delay(timeMillis = (animationDelay / circles.size).toLong() * index) + + animatable.animateTo( + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = animationDelay, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + } + } + + // container for circles + Row { + circles.forEachIndexed { index, animatable -> + // gap between the circles + if (index != 0) Spacer(modifier = Modifier.width(width = 6.dp)) + + Box( + modifier = + Modifier + .size(size = circleSize) + .clip(shape = CircleShape) + .background(circleColor.copy(alpha = animatable.value)), + ) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/NeutralButton.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/NeutralButton.kt new file mode 100644 index 0000000..f44b605 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/NeutralButton.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +@Composable +fun NeutralButton(text: String, enabled: Boolean, modifier: Modifier? = null, onClick: () -> Unit) { + val colorScheme = MaterialTheme.colorScheme + Button( + onClick = onClick, + colors = + ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary, + disabledContainerColor = colorScheme.secondary.copy(alpha = 0.4f), + ), + shape = RoundedCornerShape(16.dp), + enabled = enabled, + modifier = + modifier ?: Modifier + .height(60.dp) + .fillMaxWidth(0.9f) + .padding(vertical = 4.dp, horizontal = 8.dp), + ) { + Text( + text = text, + fontFamily = inter, + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/RadioButtonWithLabel.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/RadioButtonWithLabel.kt new file mode 100644 index 0000000..9a702da --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/RadioButtonWithLabel.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +@Composable +fun RadioButtonWithLabel(label: String, isSelected: Boolean, onSelect: () -> Unit) { + val colorScheme = MaterialTheme.colorScheme + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + Modifier + .padding(0.dp) + .selectable( + selected = isSelected, + onClick = onSelect, + ), + ) { + RadioButton( + selected = isSelected, + onClick = onSelect, + colors = + RadioButtonDefaults.colors( + selectedColor = colorScheme.primary, + unselectedColor = colorScheme.outline, + ), + modifier = + Modifier + .padding(start = 8.dp) + .size(40.dp), + ) + Text( + text = label, + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 12.sp, + modifier = + Modifier + .clickable(onClick = onSelect) + .padding(0.dp), + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/SecondaryScreensAppBar.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/SecondaryScreensAppBar.kt new file mode 100644 index 0000000..f572c5b --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/SecondaryScreensAppBar.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SecondaryScreensAppBar(title: String, navigation: () -> Unit) { + val colorScheme = MaterialTheme.colorScheme + TopAppBar( + title = { + Text( + text = title, + color = colorScheme.onSurface, + fontSize = 18.sp, + fontFamily = inter, + fontWeight = FontWeight.Medium, + ) + }, + navigationIcon = { + IconButton(onClick = navigation) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = colorScheme.surface, + ), + ) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/TransactionCards.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/TransactionCards.kt new file mode 100644 index 0000000..f2b04b1 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/TransactionCards.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.data.TxDetails +import org.bitcoindevkit.devkitwallet.domain.utils.timestampToString +import org.bitcoindevkit.devkitwallet.presentation.theme.DayGlowHistoryAccent +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet.viewTransaction + +private const val TAG = "TransactionCards" + +@Composable +fun ConfirmedTransactionCard(details: TxDetails, navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + Row( + Modifier + .padding(horizontal = 8.dp, vertical = 6.dp) + .fillMaxWidth() + .background( + color = colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), + ).border( + width = 1.dp, + color = colorScheme.outline.copy(alpha = 0.08f), + shape = RoundedCornerShape(16.dp), + ).clickable { viewTransaction(navController = navController, txid = details.txid) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + ) { + Text( + confirmedTransactionsItem(details), + fontFamily = inter, + fontSize = 12.sp, + lineHeight = 20.sp, + color = colorScheme.onSurface, + modifier = Modifier.padding(16.dp), + ) + Box( + modifier = + Modifier + .padding(top = 16.dp, end = 16.dp) + .size(24.dp) + .clip(shape = CircleShape) + .background(colorScheme.tertiary.copy(alpha = 0.6f)) + .align(Alignment.Top), + ) + } +} + +@Composable +fun PendingTransactionCard(details: TxDetails, navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + Row( + Modifier + .padding(horizontal = 8.dp, vertical = 6.dp) + .fillMaxWidth() + .background( + color = colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), + ).border( + width = 1.5.dp, + color = DayGlowHistoryAccent.copy(alpha = 0.5f), + shape = RoundedCornerShape(16.dp), + ).clickable { + viewTransaction( + navController = navController, + txid = details.txid, + ) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + ) { + Text( + pendingTransactionsItem(details), + fontFamily = inter, + fontSize = 12.sp, + color = colorScheme.onSurface, + modifier = Modifier.padding(16.dp), + ) + Box( + modifier = + Modifier + .padding(top = 16.dp, end = 16.dp) + .size(24.dp) + .clip(shape = CircleShape) + .background(DayGlowHistoryAccent.copy(alpha = 0.6f)) + .align(Alignment.Top), + ) + } +} + +fun pendingTransactionsItem(txDetails: TxDetails): String { + return buildString { + Log.i(TAG, "Pending transaction list item: $txDetails") + + appendLine("Confirmation time: Pending") + appendLine("Received: ${txDetails.received}") + appendLine("Sent: ${txDetails.sent}") + appendLine("Total fee: ${txDetails.fee} sat") + appendLine("Fee rate: ${txDetails.feeRate?.toSatPerVbCeil() ?: 0} sat/vbyte") + append("Txid: ${txDetails.txid.take(n = 8)}...${txDetails.txid.takeLast(n = 8)}") + } +} + +fun confirmedTransactionsItem(txDetails: TxDetails): String { + return buildString { + Log.i(TAG, "Transaction list item: $txDetails") + + appendLine("Confirmation time: ${txDetails.confirmationTimestamp?.timestamp?.timestampToString()}") + appendLine("Received: ${txDetails.received} sat") + appendLine("Sent: ${txDetails.sent} sat") + appendLine("Total fee: ${txDetails.fee} sat") + appendLine("Fee rate: ${txDetails.feeRate?.toSatPerVbCeil() ?: 0} sat/vbyte") + appendLine("Block: ${txDetails.confirmationBlock?.height}") + append("Txid: ${txDetails.txid.take(n = 8)}...${txDetails.txid.takeLast(n = 8)}") + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/WalletOptionsCard.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/WalletOptionsCard.kt new file mode 100644 index 0000000..b5d5c41 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/components/WalletOptionsCard.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletScriptType +import org.bitcoindevkit.devkitwallet.domain.supportedNetworks +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro.displayString + +@Composable +fun WalletOptionsCard( + scriptTypes: List, + selectedNetwork: MutableState, + selectedScriptType: MutableState, +) { + val colorScheme = MaterialTheme.colorScheme + Column( + Modifier + .fillMaxWidth() + .background( + color = colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Text( + text = "Network", + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp, bottom = 8.dp), + ) + + HorizontalDivider( + color = colorScheme.outline.copy(alpha = 0.15f), + thickness = 1.dp, + modifier = Modifier.padding(bottom = 8.dp), + ) + + supportedNetworks.forEachIndexed { index, it -> + RadioButtonWithLabel( + label = it.displayString(), + isSelected = selectedNetwork.value == it, + onSelect = { selectedNetwork.value = it }, + ) + if (index == 2) Spacer(modifier = Modifier.padding(bottom = 8.dp)) + } + + Text( + text = "Script Type", + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 16.dp, bottom = 8.dp), + ) + + HorizontalDivider( + color = colorScheme.outline.copy(alpha = 0.15f), + thickness = 1.dp, + modifier = Modifier.padding(bottom = 8.dp), + ) + + scriptTypes.forEachIndexed { index, it -> + RadioButtonWithLabel( + label = it.displayString(), + isSelected = selectedScriptType.value == it, + onSelect = { selectedScriptType.value = it }, + ) + if (index == 1) Spacer(modifier = Modifier.padding(bottom = 8.dp)) + } + } +} + +@Composable +fun NetworkOptionsCard(selectedNetwork: MutableState) { + val colorScheme = MaterialTheme.colorScheme + Column( + Modifier + .fillMaxWidth() + .background( + color = colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + ) { + Text( + text = "Network", + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp, bottom = 8.dp), + ) + + HorizontalDivider( + color = colorScheme.outline.copy(alpha = 0.15f), + thickness = 1.dp, + modifier = Modifier.padding(bottom = 8.dp), + ) + + supportedNetworks.forEachIndexed { index, it -> + RadioButtonWithLabel( + label = it.displayString(), + isSelected = selectedNetwork.value == it, + onSelect = { selectedNetwork.value = it }, + ) + if (index == 2) Spacer(modifier = Modifier.padding(bottom = 8.dp)) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/ActiveWalletsScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/ActiveWalletsScreen.kt new file mode 100644 index 0000000..ff638d3 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/ActiveWalletsScreen.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.Lucide +import org.bitcoindevkit.devkitwallet.data.datastore.StoredWallet +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType +import org.bitcoindevkit.devkitwallet.presentation.theme.NightGlowSubtle +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +private const val TAG = "ActiveWalletsScreen" + +@Composable +internal fun ActiveWalletsScreen( + activeWallets: List, + navController: NavController, + onBuildWalletButtonClicked: (WalletCreateType) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar(title = "Choose a Wallet", navigation = { navController.navigateUp() }) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (activeWallets.isEmpty()) { + Text( + text = "No active wallets.", + fontFamily = inter, + fontSize = 14.sp, + color = NightGlowSubtle, + ) + } else { + activeWallets.forEach { wallet -> + OutlinedCard( + onClick = { + DwLogger.log(INFO, "Activating existing wallet: ${wallet.name}") + onBuildWalletButtonClicked(WalletCreateType.LOADEXISTING(wallet)) + }, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.5.dp, colorScheme.outline.copy(alpha = 0.12f)), + colors = CardDefaults.outlinedCardColors(containerColor = Color.Transparent), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = wallet.name, + fontFamily = inter, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + WalletChip(text = wallet.network.name) + WalletChip(text = wallet.scriptType.name) + } + } + Icon( + imageVector = Lucide.ChevronRight, + contentDescription = "Open", + tint = colorScheme.outlineVariant, + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun WalletChip(text: String) { + val colorScheme = MaterialTheme.colorScheme + Text( + text = text, + fontFamily = inter, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(0.dp), + ) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/CreateNewWallet.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/CreateNewWallet.kt new file mode 100644 index 0000000..3da5792 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/CreateNewWallet.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.Network +import org.bitcoindevkit.devkitwallet.data.NewWalletConfig +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletScriptType +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.supportedNetworks +import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType +import org.bitcoindevkit.devkitwallet.presentation.theme.NightGlowSubtle +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun CreateNewWalletScreen( + navController: NavController, + onBuildWalletButtonClicked: (WalletCreateType) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + val walletName = remember { mutableStateOf("") } + val selectedNetwork: MutableState = remember { mutableStateOf(Network.SIGNET) } + val selectedScriptType: MutableState = + remember { mutableStateOf(ActiveWalletScriptType.P2TR) } + val scriptTypes = listOf(ActiveWalletScriptType.P2TR, ActiveWalletScriptType.P2WPKH) + + Scaffold( + topBar = { + SecondaryScreensAppBar(title = "Create a New Wallet", navigation = { navController.navigateUp() }) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp), + ) { + Spacer(Modifier.height(20.dp)) + + // Wallet name + FormLabel("Wallet Name") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = walletName.value, + onValueChange = { walletName.value = it }, + placeholder = { + Text( + text = "Give your wallet a name", + color = colorScheme.outlineVariant, + fontFamily = inter, + fontSize = 15.sp, + ) + }, + singleLine = true, + textStyle = TextStyle(fontFamily = inter, color = colorScheme.onSurface, fontSize = 15.sp), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colorScheme.primary, + focusedBorderColor = colorScheme.primary.copy(alpha = 0.40f), + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.15f), + ), + shape = RoundedCornerShape(16.dp), + ) + + Spacer(Modifier.height(24.dp)) + + // Network + FormLabel("Network") + OptionGroup { + supportedNetworks.forEach { network -> + ThemedRadioOption( + label = network.displayString(), + isSelected = selectedNetwork.value == network, + onSelect = { selectedNetwork.value = network }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Script Type + FormLabel("Script Type") + OptionGroup { + scriptTypes.forEach { scriptType -> + ThemedRadioOption( + label = scriptType.displayString(), + isSelected = selectedScriptType.value == scriptType, + onSelect = { selectedScriptType.value = scriptType }, + ) + } + } + + Spacer(Modifier.weight(1f)) + + // Create button + Button( + onClick = { + val newWalletConfig = NewWalletConfig( + name = walletName.value, + network = selectedNetwork.value, + scriptType = selectedScriptType.value, + ) + DwLogger.log(INFO, "Creating new wallet named ${newWalletConfig.name}") + onBuildWalletButtonClicked(WalletCreateType.FROMSCRATCH(newWalletConfig)) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.tertiary, + contentColor = colorScheme.onTertiary, + ), + ) { + Text( + text = "Create Wallet", + fontFamily = inter, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(40.dp)) + } + } +} + +@Composable +internal fun FormLabel(text: String) { + Text( + text = text.uppercase(), + fontFamily = inter, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = NightGlowSubtle, + letterSpacing = 1.5.sp, + modifier = Modifier.padding(bottom = 10.dp), + ) +} + +@Composable +internal fun OptionGroup(content: @Composable () -> Unit) { + val colorScheme = MaterialTheme.colorScheme + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(20.dp), + ).padding(8.dp), + ) { + content() + } +} + +@Composable +internal fun ThemedRadioOption(label: String, isSelected: Boolean, onSelect: () -> Unit) { + val colorScheme = MaterialTheme.colorScheme + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .selectable(selected = isSelected, onClick = onSelect) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Box( + modifier = Modifier + .size(22.dp) + .border( + width = 2.dp, + color = if (isSelected) colorScheme.tertiary else colorScheme.outlineVariant, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(10.dp) + .background(colorScheme.tertiary, CircleShape), + ) + } + } + Text( + text = label, + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + ) + } +} + +fun ActiveWalletScriptType.displayString(): String { + return when (this) { + ActiveWalletScriptType.P2TR -> "P2TR (Taproot, BIP-86)" + ActiveWalletScriptType.P2WPKH -> "P2WPKH (Native Segwit, BIP-84)" + ActiveWalletScriptType.UNKNOWN -> TODO() + } +} + +fun Network.displayString(): String { + return when (this) { + Network.TESTNET -> "Testnet 3" + Network.TESTNET4 -> "Testnet 4" + Network.REGTEST -> "Regtest" + Network.SIGNET -> "Signet" + Network.BITCOIN -> TODO() + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/OnboardingScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/OnboardingScreen.kt new file mode 100644 index 0000000..e960236 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/OnboardingScreen.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.R +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +private val surface = Color(0xFF1C1B1F) +private val onSurface = Color(0xFFE6E1E5) +private val subtle = Color(0xFF79747E) +private val accent = Color(0xFFF2D2B6) + +@Composable +fun OnboardingScreen(onFinishOnboarding: () -> Unit) { + val pagerState = rememberPagerState(initialPage = 1, pageCount = { 4 }) + val coroutineScope = rememberCoroutineScope() + + @Suppress("ktlint:standard:max-line-length") + val messages = listOf( + "Easter egg #1: \uD83E\uDD5A", + "Welcome to the Devkit Wallet! This app is a playground for developers and bitcoin enthusiasts to experiment with bitcoin's test networks.", + "It is developed with the Bitcoin Dev Kit, a powerful set of libraries produced and maintained by the Bitcoin Dev Kit Foundation.\n\nThis version of the app is using Compact Block Filters to sync its wallets.", + "The Foundation maintains this app as a way to showcase the capabilities of the Bitcoin Dev Kit and to provide a starting point for developers to build their own apps.\n\nIt is not a production application, and only works for testnet3, testnet4, signet, and regtest. Have fun!" + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(surface) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(120.dp)) + + // Logo + Box( + modifier = Modifier + .size(100.dp) + .border( + width = 2.dp, + color = accent.copy(alpha = 0.20f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.bdk_logo), + contentDescription = "Bitcoin Dev Kit logo", + modifier = Modifier.size(56.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = "Devkit Wallet", + fontFamily = inter, + fontSize = 24.sp, + fontWeight = FontWeight.Light, + color = onSurface, + ) + Text( + text = "BITCOIN DEVELOPMENT KIT", + fontFamily = inter, + fontSize = 11.sp, + color = subtle, + letterSpacing = 1.5.sp, + ) + + Spacer(Modifier.height(48.dp)) + + // Pager + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { page -> + Text( + text = messages[page], + fontFamily = inter, + fontSize = 15.sp, + lineHeight = 24.sp, + color = onSurface.copy(alpha = 0.85f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + } + + // Page indicators + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 32.dp), + ) { + repeat(3) { index -> + val isSelected = pagerState.currentPage == index + 1 + Box( + modifier = Modifier + .padding(horizontal = 6.dp) + .size(if (isSelected) 10.dp else 8.dp) + .clip(CircleShape) + .background( + if (isSelected) accent else accent.copy(alpha = 0.25f) + ), + ) + } + } + + // Navigation buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 40.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Previous", + fontFamily = inter, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = subtle, + modifier = Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + coroutineScope.launch { + pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceIn(0, 3)) + } + }.border( + width = 1.5.dp, + color = subtle.copy(alpha = 0.20f), + shape = RoundedCornerShape(12.dp), + ).padding(horizontal = 20.dp, vertical = 10.dp), + ) + Text( + text = if (pagerState.currentPage < 3) "Next" else "Get Started", + fontFamily = inter, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (pagerState.currentPage < 3) onSurface else surface, + modifier = Modifier + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + if (pagerState.currentPage < 3) { + coroutineScope.launch { + pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceIn(0, 3)) + } + } else { + onFinishOnboarding() + } + }.then( + if (pagerState.currentPage < 3) { + Modifier.border( + width = 1.5.dp, + color = accent.copy(alpha = 0.30f), + shape = RoundedCornerShape(12.dp), + ) + } else { + Modifier + .background(accent, RoundedCornerShape(12.dp)) + } + ).padding(horizontal = 20.dp, vertical = 10.dp), + ) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt new file mode 100644 index 0000000..14e20ff --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt @@ -0,0 +1,404 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro + +import android.util.Log +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import kotlinx.coroutines.launch +import org.bitcoindevkit.Descriptor +import org.bitcoindevkit.DescriptorSecretKey +import org.bitcoindevkit.KeychainKind +import org.bitcoindevkit.Mnemonic +import org.bitcoindevkit.Network +import org.bitcoindevkit.NetworkKind +import org.bitcoindevkit.devkitwallet.data.RecoverWalletConfig +import org.bitcoindevkit.devkitwallet.data.datastore.ActiveWalletScriptType +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.bip39WordList +import org.bitcoindevkit.devkitwallet.domain.createScriptAppropriateDescriptor +import org.bitcoindevkit.devkitwallet.domain.supportedNetworks +import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun RecoverWalletScreen(onAction: (WalletCreateType) -> Unit, navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var selectedTab by remember { mutableStateOf(0) } + val tabs = listOf("Recovery Phrase", "Descriptor") + + var walletName by remember { mutableStateOf("") } + val selectedNetwork: MutableState = remember { mutableStateOf(Network.SIGNET) } + val selectedScriptType: MutableState = + remember { mutableStateOf(ActiveWalletScriptType.P2TR) } + val scriptTypes = listOf(ActiveWalletScriptType.P2TR, ActiveWalletScriptType.P2WPKH) + + var recoveryPhrase by remember { mutableStateOf("") } + var descriptorString by remember { mutableStateOf("") } + var changeDescriptorString by remember { mutableStateOf("") } + + Scaffold( + topBar = { + SecondaryScreensAppBar(title = "Recover a Wallet", navigation = { navController.navigateUp() }) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(20.dp)) + + // Tab selector + FormLabel("Recovery Method") + Row( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(20.dp), + ).padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + tabs.forEachIndexed { index, label -> + val isSelected = selectedTab == index + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .selectable(selected = isSelected, onClick = { selectedTab = index }) + .then( + if (isSelected) { + Modifier.border( + width = 1.5.dp, + color = colorScheme.primary.copy(alpha = 0.30f), + shape = RoundedCornerShape(16.dp), + ) + } else { + Modifier + } + ).padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = label, + fontFamily = inter, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, + color = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + + // Wallet name (always shown) + FormLabel("Wallet Name") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = walletName, + onValueChange = { walletName = it }, + placeholder = { + Text( + text = "Give your wallet a name", + color = colorScheme.outlineVariant, + fontFamily = inter, + fontSize = 15.sp, + ) + }, + singleLine = true, + textStyle = TextStyle(fontFamily = inter, color = colorScheme.onSurface, fontSize = 15.sp), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colorScheme.primary, + focusedBorderColor = colorScheme.primary.copy(alpha = 0.40f), + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.15f), + ), + shape = RoundedCornerShape(16.dp), + ) + + Spacer(Modifier.height(24.dp)) + + if (selectedTab == 0) { + // Recovery Phrase tab + // Network + FormLabel("Network") + OptionGroup { + supportedNetworks.forEach { network -> + ThemedRadioOption( + label = network.displayString(), + isSelected = selectedNetwork.value == network, + onSelect = { selectedNetwork.value = network }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Script Type + FormLabel("Script Type") + OptionGroup { + scriptTypes.forEach { scriptType -> + ThemedRadioOption( + label = scriptType.displayString(), + isSelected = selectedScriptType.value == scriptType, + onSelect = { selectedScriptType.value = scriptType }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Recovery phrase input + FormLabel("Recovery Phrase") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = recoveryPhrase, + onValueChange = { recoveryPhrase = it }, + placeholder = { + Text( + text = "Enter your 12-word recovery phrase", + color = colorScheme.outlineVariant, + fontFamily = inter, + fontSize = 15.sp, + ) + }, + singleLine = false, + minLines = 3, + textStyle = TextStyle(fontFamily = inter, color = colorScheme.onSurface, fontSize = 14.sp), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colorScheme.primary, + focusedBorderColor = colorScheme.primary.copy(alpha = 0.40f), + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.15f), + ), + shape = RoundedCornerShape(16.dp), + ) + } else { + // Descriptor tab + // Network + FormLabel("Network") + OptionGroup { + supportedNetworks.forEach { network -> + ThemedRadioOption( + label = network.displayString(), + isSelected = selectedNetwork.value == network, + onSelect = { selectedNetwork.value = network }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Descriptor input + FormLabel("Descriptor") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = descriptorString, + onValueChange = { descriptorString = it }, + placeholder = { + Text( + text = "Input your descriptor here", + color = colorScheme.outlineVariant, + fontFamily = inter, + fontSize = 15.sp, + ) + }, + singleLine = false, + minLines = 4, + textStyle = TextStyle(fontFamily = inter, color = colorScheme.onSurface, fontSize = 13.sp), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colorScheme.primary, + focusedBorderColor = colorScheme.primary.copy(alpha = 0.40f), + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.15f), + ), + shape = RoundedCornerShape(16.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Change descriptor input + FormLabel("Change Descriptor") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = changeDescriptorString, + onValueChange = { changeDescriptorString = it }, + placeholder = { + Text( + text = "Input your change descriptor here", + color = colorScheme.outlineVariant, + fontFamily = inter, + fontSize = 15.sp, + ) + }, + singleLine = false, + minLines = 4, + textStyle = TextStyle(fontFamily = inter, color = colorScheme.onSurface, fontSize = 13.sp), + colors = OutlinedTextFieldDefaults.colors( + cursorColor = colorScheme.primary, + focusedBorderColor = colorScheme.primary.copy(alpha = 0.40f), + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.15f), + ), + shape = RoundedCornerShape(16.dp), + ) + } + + Spacer(Modifier.weight(1f)) + + // Recover button + Button( + onClick = { + if (selectedTab == 0) { + // Recovery phrase flow + if (recoveryPhrase.isEmpty()) { + scope.launch { + snackbarHostState.showSnackbar( + "You must provide a recovery phrase to recover a wallet.", + ) + } + return@Button + } + Log.i("RecoverWalletScreen", "Recovering wallet with recovery phrase") + val parsingResult = parseRecoveryPhrase(recoveryPhrase) + + if (parsingResult is RecoveryPhraseValidationResult.Invalid) { + scope.launch { + snackbarHostState.showSnackbar(parsingResult.reason) + } + } else if (parsingResult is RecoveryPhraseValidationResult.ProbablyValid) { + val mnemonic = Mnemonic.fromString(parsingResult.recoveryPhrase) + val bip32ExtendedRootKey = DescriptorSecretKey(NetworkKind.TEST, mnemonic, null) + val descriptor = + createScriptAppropriateDescriptor( + scriptType = selectedScriptType.value, + bip32ExtendedRootKey = bip32ExtendedRootKey, + keychain = KeychainKind.EXTERNAL, + ) + val changeDescriptor = + createScriptAppropriateDescriptor( + scriptType = selectedScriptType.value, + bip32ExtendedRootKey = bip32ExtendedRootKey, + keychain = KeychainKind.INTERNAL, + ) + val recoverWalletConfig = + RecoverWalletConfig( + name = walletName, + network = selectedNetwork.value, + scriptType = selectedScriptType.value, + descriptor = descriptor, + changeDescriptor = changeDescriptor, + recoveryPhrase = parsingResult.recoveryPhrase, + ) + DwLogger.log(INFO, "Recovering wallet with recovery phrase (name: $walletName)") + onAction(WalletCreateType.RECOVER(recoverWalletConfig)) + } + } else { + // Descriptor flow + if (descriptorString.isEmpty() || changeDescriptorString.isEmpty()) { + scope.launch { + snackbarHostState.showSnackbar( + "You must provide both a descriptor and a change descriptor.", + ) + } + return@Button + } + Log.i("RecoverWalletScreen", "Recovering wallet with descriptors") + val descriptor = Descriptor(descriptorString, NetworkKind.TEST) + val changeDescriptor = Descriptor(changeDescriptorString, NetworkKind.TEST) + val recoverWalletConfig = + RecoverWalletConfig( + name = walletName, + network = selectedNetwork.value, + scriptType = null, + descriptor = descriptor, + changeDescriptor = changeDescriptor, + recoveryPhrase = null, + ) + DwLogger.log(INFO, "Recovering wallet with descriptors (name: $walletName)") + onAction(WalletCreateType.RECOVER(recoverWalletConfig)) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary, + ), + ) { + Text( + text = "Recover Wallet", + fontFamily = inter, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(40.dp)) + } + } +} + +private fun parseRecoveryPhrase(recoveryPhrase: String): RecoveryPhraseValidationResult { + val words = recoveryPhrase.trim().split(" ") + if (words.size != 12) { + return RecoveryPhraseValidationResult.Invalid("Recovery phrase must have 12 words") + } + if (words.any { it !in bip39WordList }) { + return RecoveryPhraseValidationResult.Invalid("Invalid word in recovery phrase") + } + return RecoveryPhraseValidationResult.ProbablyValid(recoveryPhrase) +} + +sealed class RecoveryPhraseValidationResult { + data class ProbablyValid(val recoveryPhrase: String) : RecoveryPhraseValidationResult() + + data class Invalid(val reason: String) : RecoveryPhraseValidationResult() +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/WalletChoiceScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/WalletChoiceScreen.kt new file mode 100644 index 0000000..84749ce --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/WalletChoiceScreen.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.intro + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.composables.icons.lucide.List +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Plus +import com.composables.icons.lucide.RotateCcw +import org.bitcoindevkit.devkitwallet.presentation.navigation.ActiveWalletsScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.CreateNewWalletScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.WalletRecoveryScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.NightGlowSubtle +import org.bitcoindevkit.devkitwallet.presentation.theme.inter + +@Composable +internal fun WalletChoiceScreen(navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Logo area + Box( + modifier = Modifier + .size(72.dp) + .border( + width = 2.dp, + color = colorScheme.primary.copy(alpha = 0.20f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "\u20BF", + color = colorScheme.primary, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(Modifier.height(20.dp)) + + Text( + text = "Devkit Wallet", + fontFamily = inter, + fontSize = 28.sp, + fontWeight = FontWeight.Light, + color = colorScheme.onSurface, + ) + Text( + text = "BITCOIN DEVELOPMENT KIT", + fontSize = 13.sp, + color = NightGlowSubtle, + letterSpacing = 1.sp, + ) + + Spacer(Modifier.height(64.dp)) + + // Buttons + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + WelcomeButton( + icon = Lucide.List, + iconTint = colorScheme.primary, + title = "Use an Active Wallet", + description = "Open an existing wallet on this device", + borderColor = colorScheme.outline.copy(alpha = 0.15f), + onClick = { navController.navigate(ActiveWalletsScreen) }, + ) + WelcomeButton( + icon = Lucide.Plus, + iconTint = colorScheme.tertiary, + title = "Create a New Wallet", + description = "Generate fresh keys and start from scratch", + borderColor = colorScheme.outline.copy(alpha = 0.15f), + onClick = { navController.navigate(CreateNewWalletScreen) }, + ) + WelcomeButton( + icon = Lucide.RotateCcw, + iconTint = colorScheme.secondary, + title = "Recover an Existing Wallet", + description = "Import from descriptor or recovery phrase", + borderColor = colorScheme.outline.copy(alpha = 0.15f), + onClick = { navController.navigate(WalletRecoveryScreen) }, + ) + } + } + } +} + +@Composable +private fun WelcomeButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + iconTint: Color, + title: String, + description: String, + borderColor: Color, + onClick: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.5.dp, borderColor), + colors = CardDefaults.outlinedCardColors(containerColor = Color.Transparent), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(44.dp) + .border( + width = 1.dp, + color = iconTint.copy(alpha = 0.15f), + shape = RoundedCornerShape(14.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(22.dp), + ) + } + Column { + Text( + text = title, + fontFamily = inter, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + fontFamily = inter, + fontSize = 12.sp, + color = NightGlowSubtle, + ) + } + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/AboutScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/AboutScreen.kt new file mode 100644 index 0000000..e14c22f --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/AboutScreen.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import org.bitcoindevkit.devkitwallet.R +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +private val message: String = + """ + This wallet is build for: + + 1. Developers interested in learning how to leverage the Bitcoin Development Kit on Android. + + 2. Any bitcoiner looking for a Signet/Testnet/Regtest wallet! + """.trimIndent() + +@Composable +internal fun AboutScreen(navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + val mUriHandler = LocalUriHandler.current + val openSourceRepository = + remember { { mUriHandler.openUri("https://github.com/bitcoindevkit/bdk-kotlin-example-wallet") } } + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "About", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 100.dp), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.padding(24.dp)) + Image( + painter = painterResource(id = R.drawable.bdk_logo), + contentDescription = "Old School BDK Logo", + Modifier.size(180.dp), + ) + Spacer(modifier = Modifier.padding(24.dp)) + Text( + text = message, + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 16.sp, + lineHeight = 26.sp, + modifier = Modifier.padding(all = 8.dp), + ) + Spacer(modifier = Modifier.padding(8.dp)) + Text( + text = "You are using the Compact Block Filters (CBF) version of the wallet.", + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 16.sp, + lineHeight = 26.sp, + modifier = Modifier.padding(all = 8.dp), + ) + Spacer(modifier = Modifier.padding(8.dp)) + Text( + text = "Check out the source code for the wallet on GitHub.", + color = colorScheme.primary, + fontFamily = inter, + fontSize = 16.sp, + textDecoration = TextDecoration.Underline, + lineHeight = 26.sp, + modifier = + Modifier + .padding(all = 8.dp) + .clickable(onClick = openSourceRepository), + ) + } + } +} + +@Preview(device = Devices.PIXEL_4, showBackground = true) +@Composable +internal fun PreviewAboutScreen() { + AboutScreen(rememberNavController()) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt new file mode 100644 index 0000000..7b4e1e6 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/BlockchainClientScreen.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.CbfNodeStatus +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState + +@Composable +internal fun BlockchainClientScreen( + state: WalletScreenState, + onAction: (WalletScreenAction) -> Unit, + navController: NavController, +) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Compact Block Filters Node", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(vertical = 32.dp, horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + val status = if (state.kyotoNodeStatus == CbfNodeStatus.Running) "Online" else "Offline" + Text( + text = "CBF Node Status: $status", + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + Box( + modifier = + Modifier + .padding(horizontal = 8.dp) + .size(size = 21.dp) + .clip(shape = CircleShape) + .background( + if (state.kyotoNodeStatus == CbfNodeStatus.Running) { + Color(0xFF8FD998) + } else { + Color(0xFFE76F51) + } + ), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + Text( + text = "Latest known block:", + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + Text( + text = "${state.bestBlockHeight}", + color = colorScheme.onSurface, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Start, + ) + } + + Spacer(modifier = Modifier.padding(16.dp)) + + NeutralButton( + text = "Start Node", + enabled = state.kyotoNodeStatus == CbfNodeStatus.Stopped, + onClick = { onAction(WalletScreenAction.ActivateCbfNode) }, + ) + NeutralButton( + text = "Stop Node", + enabled = state.kyotoNodeStatus == CbfNodeStatus.Running, + onClick = { onAction(WalletScreenAction.StopKyotoNode) }, + ) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CustomBlockchainClient.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CustomBlockchainClient.kt new file mode 100644 index 0000000..7cff349 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CustomBlockchainClient.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun CustomBlockchainClient(navController: NavController) { + val focusManager = LocalFocusManager.current + val electrumServer: MutableState = remember { mutableStateOf("") } + val isChecked: MutableState = remember { mutableStateOf(false) } + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Custom Blockchain Client", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(all = 16.dp), + ) { + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/LogsScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/LogsScreen.kt new file mode 100644 index 0000000..b1118af --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/LogsScreen.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +fun LogsScreen(navController: NavController) { + val logs: List = remember { DwLogger.getLogs() } + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Logs", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + ) { + items(logs) { logLine -> + Text( + text = logLine, + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Visible, + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) + } + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/RecoveryDataScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/RecoveryDataScreen.kt new file mode 100644 index 0000000..d02b277 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/RecoveryDataScreen.kt @@ -0,0 +1,226 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.composables.icons.lucide.ClipboardCopy +import com.composables.icons.lucide.Lucide +import org.bitcoindevkit.devkitwallet.domain.WalletSecrets +import org.bitcoindevkit.devkitwallet.presentation.theme.googleSansCode +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +private val MESSAGE: String = + """ + The next screen will show your recovery phrase and descriptors. Make sure no one else is looking at your screen. + """.trimIndent() + +@Composable +internal fun RecoveryDataScreen(walletSecrets: WalletSecrets, navController: NavController) { + val (currentIndex, setCurrentIndex) = remember { mutableIntStateOf(0) } + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Your Wallet Recovery Data", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Crossfade( + modifier = Modifier.padding(paddingValues), + targetState = currentIndex, + label = "", + animationSpec = + tween( + durationMillis = 1000, + delayMillis = 200, + ), + ) { screen -> + when (screen) { + 0 -> WarningText(setCurrentIndex = setCurrentIndex) + 1 -> RecoveryPhrase(walletSecrets = walletSecrets) + } + } + } +} + +@Composable +fun WarningText(setCurrentIndex: (Int) -> Unit) { + val colorScheme = MaterialTheme.colorScheme + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = MESSAGE, + color = colorScheme.onSurface, + fontFamily = inter, + ) + Spacer(modifier = Modifier.padding(16.dp)) + NeutralButton( + text = "See my recovery data", + enabled = true, + ) { setCurrentIndex(1) } + } +} + +@Composable +fun RecoveryPhrase(walletSecrets: WalletSecrets) { + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + Column( + modifier = + Modifier + .fillMaxSize() + .padding(all = 32.dp), + ) { + Text( + text = "Write down your recovery phrase and keep it in a safe place.", + color = colorScheme.onSurface, + fontFamily = inter, + ) + Spacer(modifier = Modifier.padding(8.dp)) + Box { + SelectionContainer { + Text( + modifier = + Modifier + .clickable { + simpleCopyClipboard( + walletSecrets.recoveryPhrase, + context, + ) + }.background( + color = colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ).padding(12.dp), + text = walletSecrets.recoveryPhrase, + fontFamily = googleSansCode, + color = colorScheme.onSurface, + ) + } + Icon( + Lucide.ClipboardCopy, + tint = colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = "Copy to clipboard", + modifier = + Modifier + .padding(8.dp) + .size(20.dp) + .align(Alignment.BottomEnd), + ) + } + Spacer(modifier = Modifier.padding(16.dp)) + Text( + text = "These are your descriptors.", + color = colorScheme.onSurface, + fontFamily = inter, + ) + Spacer(modifier = Modifier.padding(8.dp)) + Box { + SelectionContainer { + Text( + modifier = + Modifier + .clickable { + simpleCopyClipboard( + walletSecrets.descriptor.toStringWithSecret(), + context, + ) + }.background( + color = colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ).padding(12.dp), + text = walletSecrets.descriptor.toStringWithSecret(), + fontFamily = googleSansCode, + color = colorScheme.onSurface, + ) + } + Icon( + Lucide.ClipboardCopy, + tint = colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = "Copy to clipboard", + modifier = + Modifier + .padding(8.dp) + .size(20.dp) + .align(Alignment.BottomEnd), + ) + } + Spacer(modifier = Modifier.padding(4.dp)) + Box { + SelectionContainer { + Text( + modifier = + Modifier + .clickable { + simpleCopyClipboard( + walletSecrets.changeDescriptor.toStringWithSecret(), + context, + ) + }.background( + color = colorScheme.surfaceVariant, + shape = RoundedCornerShape(16.dp), + ).padding(12.dp), + text = walletSecrets.changeDescriptor.toStringWithSecret(), + fontFamily = googleSansCode, + color = colorScheme.onSurface, + ) + } + Icon( + Lucide.ClipboardCopy, + tint = colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = "Copy to clipboard", + modifier = + Modifier + .padding(8.dp) + .size(20.dp) + .align(Alignment.BottomEnd), + ) + } + } +} + +fun simpleCopyClipboard(content: String, context: Context) { + val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip: ClipData = ClipData.newPlainText("", content) + clipboard.setPrimaryClip(clip) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/SettingsScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..df8fcd7 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.composables.icons.lucide.Info +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Palette +import com.composables.icons.lucide.ScrollText +import org.bitcoindevkit.devkitwallet.presentation.navigation.AboutScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.LogsScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.ThemeScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.DayGlowHistoryAccent +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun SettingsScreen(navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Settings", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(20.dp), + ).clip(RoundedCornerShape(20.dp)), + ) { + SettingsItem( + icon = Lucide.Info, + iconTint = colorScheme.primary, + title = "About", + description = "Version and project info", + onClick = { navController.navigate(AboutScreen) }, + ) + HorizontalDivider( + thickness = 1.dp, + color = colorScheme.outline.copy(alpha = 0.06f), + ) + SettingsItem( + icon = Lucide.ScrollText, + iconTint = DayGlowHistoryAccent, + title = "Logs", + description = "View application logs", + onClick = { navController.navigate(LogsScreen) }, + ) + HorizontalDivider( + thickness = 1.dp, + color = colorScheme.outline.copy(alpha = 0.06f), + ) + SettingsItem( + icon = Lucide.Palette, + iconTint = colorScheme.tertiary, + title = "Theme", + description = "Appearance and display", + onClick = { navController.navigate(ThemeScreen) }, + ) + } + } + } +} + +@Composable +private fun SettingsItem( + icon: ImageVector, + iconTint: Color, + title: String, + description: String, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(iconTint.copy(alpha = 0.08f)) + .border( + width = 1.dp, + color = iconTint.copy(alpha = 0.12f), + shape = RoundedCornerShape(14.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = iconTint, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + text = title, + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = inter, + fontSize = 12.sp, + ) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/ThemeScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/ThemeScreen.kt new file mode 100644 index 0000000..1827e3f --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/ThemeScreen.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.composables.icons.lucide.Check +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Moon +import com.composables.icons.lucide.Sun +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun ThemeScreen(useDarkTheme: Boolean, onToggleTheme: () -> Unit, navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Theme", + navigation = { navController.popBackStack() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(20.dp), + ).clip(RoundedCornerShape(20.dp)), + ) { + ThemeItem( + icon = Lucide.Sun, + iconTint = colorScheme.primary, + title = "DayGlow", + description = "Light theme", + isSelected = !useDarkTheme, + onClick = { if (useDarkTheme) onToggleTheme() }, + ) + HorizontalDivider( + thickness = 1.dp, + color = colorScheme.outline.copy(alpha = 0.06f), + ) + ThemeItem( + icon = Lucide.Moon, + iconTint = colorScheme.secondary, + title = "NightGlow", + description = "Dark theme", + isSelected = useDarkTheme, + onClick = { if (!useDarkTheme) onToggleTheme() }, + ) + } + } + } +} + +@Composable +private fun ThemeItem( + icon: ImageVector, + iconTint: Color, + title: String, + description: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(14.dp)) + .background(iconTint.copy(alpha = if (isSelected) 0.10f else 0.05f)) + .border( + width = 1.dp, + color = iconTint.copy(alpha = if (isSelected) 0.15f else 0.07f), + shape = RoundedCornerShape(14.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = if (isSelected) iconTint else iconTint.copy(alpha = 0.4f), + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = if (isSelected) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = inter, + fontSize = 15.sp, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = inter, + fontSize = 12.sp, + ) + } + if (isSelected) { + Icon( + imageVector = Lucide.Check, + contentDescription = "Selected", + tint = colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } else { + Spacer(modifier = Modifier.width(18.dp)) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/RBFScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/RBFScreen.kt new file mode 100644 index 0000000..0e77b91 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/RBFScreen.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController + +private const val TAG = "RBFScreen" + +@Composable +internal fun RBFScreen( + txid: String?, + navController: NavController, + paddingValues: PaddingValues = PaddingValues(0.dp), +) { +// if (txid.isNullOrEmpty()) { +// navController.popBackStack() +// } +// var transaction: TransactionDetails? = getTransaction(txid = txid) +// if (transaction == null) { +// navController.popBackStack() +// } +// transaction = transaction as TransactionDetails +// val context = LocalContext.current +// +// val amount = (transaction.sent - transaction.received - (transaction.fee ?: 0UL)).toString() +// val feeRate: MutableState = rememberSaveable { mutableStateOf("") } +// val (showDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) } +// +// ConstraintLayout( +// modifier = Modifier +// .fillMaxSize() +// .padding(paddingValues) +// .background(DevkitWalletColors.primary) +// ) { +// val (screenTitle, transactionInputs, bottomButtons) = createRefs() +// +// Text( +// text = "Send Bitcoin", +// color = DevkitWalletColors.white, +// fontSize = 28.sp, +// textAlign = TextAlign.Center, +// modifier = Modifier +// .constrainAs(screenTitle) { +// top.linkTo(parent.top) +// start.linkTo(parent.start) +// end.linkTo(parent.end) +// } +// .padding(top = 70.dp) +// ) +// +// Column( +// horizontalAlignment = Alignment.CenterHorizontally, +// verticalArrangement = Arrangement.Center, +// modifier = Modifier.constrainAs(transactionInputs) { +// top.linkTo(screenTitle.bottom) +// bottom.linkTo(bottomButtons.top) +// start.linkTo(parent.start) +// end.linkTo(parent.end) +// height = Dimension.fillToConstraints +// } +// ) { +// ShowTxnDetail(name = "Transaction Id",content = txid!!) +// ShowTxnDetail(name = "Amount", content = amount) +// TransactionFeeInput(feeRate = feeRate) +// BumpFeeDialog( +// txid = txid, +// amount = amount, +// feeRate = feeRate, +// showDialog = showDialog, +// setShowDialog = setShowDialog, +// context = context +// ) +// } +// Column( +// Modifier +// .constrainAs(bottomButtons) { +// bottom.linkTo(parent.bottom) +// start.linkTo(parent.start) +// end.linkTo(parent.end) +// } +// .padding(bottom = 32.dp) +// ) { +// Button( +// onClick = { setShowDialog(true) }, +// colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent2), +// shape = RoundedCornerShape(16.dp), +// modifier = Modifier +// .height(80.dp) +// .fillMaxWidth(0.9f) +// .padding(vertical = 8.dp, horizontal = 8.dp) +// .shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp)) +// ) { +// Text( +// text = "broadcast transaction", +// fontSize = 14.sp, +// textAlign = TextAlign.Center, +// lineHeight = 28.sp, +// ) +// } +// Button( +// onClick = { navController.navigate(Screen.HomeScreen.route) }, +// colors = ButtonDefaults.buttonColors(DevkitWalletColors.primaryLight), +// shape = RoundedCornerShape(16.dp), +// modifier = Modifier +// .height(80.dp) +// .fillMaxWidth(0.9f) +// .padding(vertical = 8.dp, horizontal = 8.dp) +// .shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp)) +// ) { +// Text( +// text = "back to wallet", +// fontSize = 14.sp, +// textAlign = TextAlign.Center, +// lineHeight = 28.sp, +// ) +// } +// } +// } +} +// +// @OptIn(ExperimentalMaterial3Api::class) +// @Composable +// private fun ShowTxnDetail(name: String, content: String) { +// Row( +// verticalAlignment = Alignment.CenterVertically, +// modifier = Modifier.fillMaxWidth(fraction = 0.9f) +// ) { +// OutlinedTextField( +// modifier = Modifier +// .padding(vertical = 8.dp) +// .weight(0.5f), +// value = content, +// onValueChange = { }, +// label = { +// Text( +// text = name, +// color = DevkitWalletColors.white, +// ) +// }, +// singleLine = true, +// textStyle = TextStyle(color = DevkitWalletColors.white), +// colors = TextFieldDefaults.outlinedTextFieldColors( +// focusedBorderColor = DevkitWalletColors.accent1, +// unfocusedBorderColor = DevkitWalletColors.white, +// cursorColor = DevkitWalletColors.accent1, +// ), +// enabled = false, +// ) +// } +// } +// +// @OptIn(ExperimentalMaterial3Api::class) +// @Composable +// private fun TransactionFeeInput(feeRate: MutableState) { +// Column(horizontalAlignment = Alignment.CenterHorizontally) { +// OutlinedTextField( +// modifier = Modifier +// .padding(vertical = 8.dp) +// .fillMaxWidth(0.9f), +// value = feeRate.value, +// onValueChange = { newValue: String -> +// feeRate.value = newValue.filter { it.isDigit() } +// }, +// singleLine = true, +// textStyle = TextStyle(color = DevkitWalletColors.white), +// label = { +// Text( +// text = "New fee rate", +// color = DevkitWalletColors.white, +// ) +// }, +// colors = TextFieldDefaults.outlinedTextFieldColors( +// focusedBorderColor = DevkitWalletColors.accent1, +// unfocusedBorderColor = DevkitWalletColors.white, +// cursorColor = DevkitWalletColors.accent1, +// ), +// ) +// } +// } +// +// @Composable +// fun BumpFeeDialog( +// txid: String, +// amount: String, +// showDialog: Boolean, +// setShowDialog: (Boolean) -> Unit, +// context: Context, +// feeRate: MutableState, +// ) { +// if (showDialog) { +// var confirmationText = "Confirm Transaction : \nTxid : $txid\nAmount : $amount" +// if (feeRate.value.isNotEmpty()) { +// confirmationText += "Fee Rate : ${feeRate.value.toULong()}" +// } +// AlertDialog( +// containerColor = DevkitWalletColors.primaryLight, +// onDismissRequest = {}, +// title = { +// Text( +// text = "Confirm transaction", +// color = DevkitWalletColors.white +// ) +// }, +// text = { +// Text( +// text = confirmationText, +// color = DevkitWalletColors.white +// ) +// }, +// confirmButton = { +// TextButton( +// onClick = { +// if (feeRate.value.isNotEmpty()) { +// broadcastTransaction(txid = txid, feeRate = feeRate.value.toFloat()) +// } else { +// Toast.makeText(context, "Fee is empty!", Toast.LENGTH_SHORT).show() +// } +// setShowDialog(false) +// }, +// ) { +// Text( +// text = "Confirm", +// color = DevkitWalletColors.white +// ) +// } +// }, +// dismissButton = { +// TextButton( +// onClick = { +// setShowDialog(false) +// }, +// ) { +// Text( +// text = "Cancel", +// color = DevkitWalletColors.white +// ) +// } +// }, +// ) +// } +// } +// +// private fun broadcastTransaction(txid: String, feeRate: Float = 1F) { +// Log.i(TAG, "Attempting to broadcast transaction with inputs: txid $txid, fee rate: $feeRate") +// try { +// // create, sign, and broadcast +// val psbt: PartiallySignedTransaction = Wallet.createBumpFeeTransaction(txid = txid, feeRate = feeRate) +// Wallet.sign(psbt) +// val newTxid: String = Wallet.broadcast(psbt) +// Log.i(TAG, "Transaction was broadcast! txid: $newTxid") +// } catch (e: Throwable) { +// Log.i(TAG, "Broadcast error: ${e.message}") +// } +// } diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/ReceiveScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/ReceiveScreen.kt new file mode 100644 index 0000000..9b8d70a --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/ReceiveScreen.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.Log +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.createBitmap +import androidx.navigation.NavController +import com.composables.icons.lucide.ClipboardCopy +import com.composables.icons.lucide.Lucide +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.presentation.navigation.HomeScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.googleSansCode +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenState + +private const val TAG = "ReceiveScreen" + +@Composable +internal fun ReceiveScreen( + state: ReceiveScreenState, + onAction: (ReceiveScreenAction) -> Unit, + navController: NavController, +) { + Log.i(TAG, "We are recomposing the ReceiveScreen") + val snackbarHostState = remember { SnackbarHostState() } + val colorScheme = MaterialTheme.colorScheme + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + SecondaryScreensAppBar( + title = "Receive Address", + navigation = { navController.navigate(HomeScreen) }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(24.dp)) + + val qrBitmap: ImageBitmap? = state.address?.let { addressToQR(it) } + Log.i(TAG, "New receive address is ${state.address}") + + if (qrBitmap != null) { + // QR code in outlined container + Box( + modifier = Modifier + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(24.dp), + ).clip(RoundedCornerShape(24.dp)) + .padding(20.dp), + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = qrBitmap, + contentDescription = "Bitcoin address QR code", + Modifier + .size(230.dp) + .clip(RoundedCornerShape(12.dp)), + ) + } + + Spacer(Modifier.height(24.dp)) + + // Address card with copy button + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .border( + width = 1.5.dp, + color = colorScheme.outline.copy(alpha = 0.10f), + shape = RoundedCornerShape(16.dp), + ).clip(RoundedCornerShape(16.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + text = state.address.chunked(4).joinToString(" "), + fontFamily = googleSansCode, + fontSize = 13.sp, + fontWeight = FontWeight.Light, + color = colorScheme.onSurface, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(end = 40.dp), + ) + IconButton( + onClick = { + copyToClipboard( + state.address, + context, + scope, + snackbarHostState, + null, + ) + }, + modifier = Modifier + .size(28.dp) + .align(Alignment.CenterEnd), + ) { + Icon( + Lucide.ClipboardCopy, + tint = colorScheme.onSurface.copy(alpha = 0.5f), + contentDescription = "Copy to clipboard", + modifier = Modifier.size(18.dp), + ) + } + } + + Spacer(Modifier.height(24.dp)) + + // Address index + Text( + text = "Address index: ${state.addressIndex}", + fontFamily = inter, + fontSize = 12.sp, + color = colorScheme.onSurface.copy(alpha = 0.4f), + ) + } + + // Generate new address button + OutlinedButton( + onClick = { onAction(ReceiveScreenAction.UpdateAddress) }, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.5.dp, colorScheme.primary), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .height(52.dp), + ) { + Text( + text = "Generate New Address", + fontFamily = inter, + fontSize = 15.sp, + color = colorScheme.primary, + ) + } + + Spacer(Modifier.height(32.dp)) + } + } +} + +private fun addressToQR(address: String): ImageBitmap? { + Log.i(TAG, "We are generating the QR code for address $address") + try { + val qrCodeWriter = QRCodeWriter() + val bitMatrix: BitMatrix = qrCodeWriter.encode(address, BarcodeFormat.QR_CODE, 1000, 1000) + val bitMap = createBitmap(1000, 1000) + for (x in 0 until 1000) { + for (y in 0 until 1000) { + bitMap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF1C1B1F.toInt() else 0xFFE6E1E5.toInt()) + } + } + return bitMap.asImageBitmap() + } catch (e: Throwable) { + Log.i(TAG, "Error with QRCode generation, $e") + } + return null +} + +fun copyToClipboard( + content: String, + context: Context, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, + setCopyClicked: ( + (Boolean) -> Unit + )?, +) { + val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip: ClipData = ClipData.newPlainText("", content) + clipboard.setPrimaryClip(clip) + scope.launch { + snackbarHostState.showSnackbar("Copied address to clipboard!") + delay(1000) + if (setCopyClicked != null) { + setCopyClicked(false) + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt new file mode 100644 index 0000000..12c4186 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt @@ -0,0 +1,480 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.presentation.navigation.HomeScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.SendViewModel +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.Recipient +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.SendScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.TransactionType +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.TxDataBundle + +private const val TAG = "SendScreen" + +@Composable +internal fun SendScreen(navController: NavController, sendViewModel: SendViewModel) { + val onAction = sendViewModel::onAction + val context = LocalContext.current + val colorScheme = MaterialTheme.colorScheme + + val recipientList: MutableList = remember { mutableStateListOf(Recipient(address = "", amount = 0u)) } + val feeRate: MutableState = rememberSaveable { mutableStateOf("") } + val (showDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) } + + val sendAll: MutableState = remember { mutableStateOf(false) } + val opReturnMsg: MutableState = remember { mutableStateOf(null) } + val (showAdvanced, setShowAdvanced) = rememberSaveable { mutableStateOf(false) } + + val textFieldColors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = colorScheme.primary, + unfocusedBorderColor = colorScheme.outline.copy(alpha = 0.30f), + cursorColor = colorScheme.primary, + focusedLabelColor = colorScheme.primary, + unfocusedLabelColor = colorScheme.onSurface.copy(alpha = 0.5f), + ) + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Send Bitcoin", + navigation = { navController.navigate(HomeScreen) }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(16.dp)) + + // Recipient address fields + recipientList.forEachIndexed { index, _ -> + val recipientAddress: MutableState = rememberSaveable { mutableStateOf("") } + + FormLabel(text = "Recipient address${if (recipientList.size > 1) " ${index + 1}" else ""}") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = recipientAddress.value, + onValueChange = { + recipientAddress.value = it + recipientList[index].address = it + }, + placeholder = { + Text( + text = "bc1q...", + color = colorScheme.onSurface.copy(alpha = 0.3f), + fontFamily = inter, + ) + }, + singleLine = true, + textStyle = TextStyle( + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ), + colors = textFieldColors, + shape = RoundedCornerShape(12.dp), + ) + Spacer(Modifier.height(16.dp)) + } + + // Amount fields + recipientList.forEachIndexed { index, _ -> + val amount: MutableState = rememberSaveable { mutableStateOf("") } + val transactionType = if (sendAll.value) TransactionType.SEND_ALL else TransactionType.STANDARD + + FormLabel( + text = when { + transactionType == TransactionType.SEND_ALL -> "Amount (Send All)" + recipientList.size > 1 -> "Amount ${index + 1}" + else -> "Amount" + } + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = amount.value, + onValueChange = { + amount.value = it + recipientList[index].amount = it.toULongOrNull() ?: 0u + }, + trailingIcon = { + Text( + text = "sats", + color = colorScheme.onSurface.copy(alpha = 0.4f), + fontFamily = inter, + fontSize = 13.sp, + modifier = Modifier.padding(end = 8.dp), + ) + }, + singleLine = true, + textStyle = TextStyle( + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ), + colors = textFieldColors, + shape = RoundedCornerShape(12.dp), + enabled = transactionType != TransactionType.SEND_ALL, + ) + Spacer(Modifier.height(16.dp)) + } + + // Fee rate + FormLabel(text = "Fee rate") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = feeRate.value, + onValueChange = { newValue: String -> + feeRate.value = newValue.filter { it.isDigit() } + }, + trailingIcon = { + Text( + text = "sat/vB", + color = colorScheme.onSurface.copy(alpha = 0.4f), + fontFamily = inter, + fontSize = 13.sp, + modifier = Modifier.padding(end = 8.dp), + ) + }, + singleLine = true, + textStyle = TextStyle( + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ), + colors = textFieldColors, + shape = RoundedCornerShape(12.dp), + ) + Text( + text = "Suggested: 1–10 for low priority", + color = colorScheme.onSurface.copy(alpha = 0.35f), + fontFamily = inter, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = 4.dp), + ) + + Spacer(Modifier.height(20.dp)) + + // Advanced options toggle + TextButton( + onClick = { setShowAdvanced(!showAdvanced) }, + ) { + Text( + text = if (showAdvanced) "Hide advanced options" else "Advanced options", + color = colorScheme.primary, + fontFamily = inter, + fontSize = 14.sp, + ) + } + + AnimatedVisibility(visible = showAdvanced) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Spacer(Modifier.height(8.dp)) + + // Send all switch + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Send All", + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ) + Switch( + checked = sendAll.value, + onCheckedChange = { + sendAll.value = !sendAll.value + while (recipientList.size > 1) { + recipientList.removeAt(recipientList.lastIndex) + } + }, + colors = SwitchDefaults.colors( + uncheckedBorderColor = colorScheme.outline.copy(alpha = 0.30f), + uncheckedThumbColor = colorScheme.outline, + uncheckedTrackColor = colorScheme.surface, + checkedThumbColor = colorScheme.surface, + checkedTrackColor = colorScheme.primary, + ), + ) + } + + Spacer(Modifier.height(12.dp)) + + // OP_RETURN message + FormLabel(text = "OP_RETURN message (optional)") + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = opReturnMsg.value ?: "", + onValueChange = { opReturnMsg.value = it }, + singleLine = true, + textStyle = TextStyle( + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 15.sp, + ), + colors = textFieldColors, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Number of recipients + Text( + text = "Number of Recipients", + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 14.sp, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { + if (recipientList.size > 1) { + recipientList.removeAt(recipientList.lastIndex) + } + }, + enabled = !sendAll.value, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary, + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.width(70.dp), + ) { + Text(text = "−", fontSize = 18.sp) + } + + Text( + text = "${recipientList.size}", + color = colorScheme.onSurface, + fontFamily = inter, + fontSize = 18.sp, + ) + + Button( + onClick = { recipientList.add(Recipient("", 0u)) }, + enabled = !sendAll.value, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.width(70.dp), + ) { + Text(text = "+", fontSize = 18.sp) + } + } + + Spacer(Modifier.height(16.dp)) + } + } + + Spacer(Modifier.height(24.dp)) + + // Broadcast button + Button( + onClick = { setShowDialog(true) }, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.secondary, + ), + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = "Broadcast Transaction", + fontFamily = inter, + fontSize = 15.sp, + ) + } + + Spacer(Modifier.height(32.dp)) + } + + // Confirmation dialog + ConfirmDialog( + recipientList = recipientList, + feeRate = feeRate, + showDialog = showDialog, + setShowDialog = setShowDialog, + transactionType = if (sendAll.value) TransactionType.SEND_ALL else TransactionType.STANDARD, + opReturnMsg = opReturnMsg.value, + context = context, + onAction = onAction, + ) + } +} + +@Composable +private fun FormLabel(text: String) { + val colorScheme = MaterialTheme.colorScheme + Text( + text = text, + color = colorScheme.onSurface.copy(alpha = 0.6f), + fontFamily = inter, + fontSize = 13.sp, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + ) +} + +fun checkRecipientList( + recipientList: MutableList, + feeRate: MutableState, + context: Context, +): Boolean { + if (recipientList.size > 4) { + Toast.makeText(context, "Too many recipients", Toast.LENGTH_SHORT).show() + return false + } + for (recipient in recipientList) { + if (recipient.address == "") { + Toast.makeText(context, "Address is empty", Toast.LENGTH_SHORT).show() + return false + } + } + if (feeRate.value.isBlank()) { + Toast.makeText(context, "Fee rate is empty", Toast.LENGTH_SHORT).show() + return false + } + return true +} + +@Composable +private fun ConfirmDialog( + recipientList: MutableList, + feeRate: MutableState, + showDialog: Boolean, + setShowDialog: (Boolean) -> Unit, + transactionType: TransactionType, + opReturnMsg: String?, + context: Context, + onAction: (SendScreenAction) -> Unit, +) { + if (showDialog) { + val colorScheme = MaterialTheme.colorScheme + + var confirmationText = "Confirm Transaction:\n" + recipientList.forEach { confirmationText += "${it.address}, ${it.amount}\n" } + if (feeRate.value.isNotEmpty()) { + confirmationText += "Fee Rate: ${feeRate.value.toULong()}\n" + } + if (!opReturnMsg.isNullOrEmpty()) { + confirmationText += "OP_RETURN Message: $opReturnMsg" + } + AlertDialog( + containerColor = colorScheme.surface, + onDismissRequest = {}, + title = { + Text( + text = "Confirm transaction", + color = colorScheme.onSurface, + fontFamily = inter, + ) + }, + text = { + Text( + text = confirmationText, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontFamily = inter, + fontSize = 14.sp, + ) + }, + confirmButton = { + TextButton( + onClick = { + if (checkRecipientList(recipientList = recipientList, feeRate = feeRate, context = context)) { + val txDataBundle = + TxDataBundle( + recipients = recipientList.toList(), + feeRate = feeRate.value.toULong(), + transactionType = transactionType, + opReturnMsg = opReturnMsg, + ) + onAction(SendScreenAction.Broadcast(txDataBundle)) + setShowDialog(false) + } + }, + ) { + Text( + text = "Confirm", + color = colorScheme.primary, + fontFamily = inter, + ) + } + }, + dismissButton = { + TextButton( + onClick = { setShowDialog(false) }, + ) { + Text( + text = "Cancel", + color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = inter, + ) + } + }, + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionHistoryScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionHistoryScreen.kt new file mode 100644 index 0000000..dbbfd51 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionHistoryScreen.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.navigation.HomeScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.TransactionScreen +import org.bitcoindevkit.devkitwallet.presentation.ui.components.ConfirmedTransactionCard +import org.bitcoindevkit.devkitwallet.presentation.ui.components.PendingTransactionCard +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +private const val TAG = "TransactionHistoryScreen" + +@Composable +internal fun TransactionHistoryScreen(navController: NavController, activeWallet: Wallet) { + val (pendingTransactions, confirmedTransactions) = activeWallet.getAllTxDetails().partition { it.pending } + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Transaction History", + navigation = { navController.navigate(HomeScreen) }, + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { paddingValues -> + val scrollState = rememberScrollState() + Column( + modifier = + Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(top = 6.dp) + .verticalScroll(state = scrollState), + ) { + if (pendingTransactions.isNotEmpty()) { + pendingTransactions.forEach { + PendingTransactionCard(details = it, navController = navController) + } + } + if (confirmedTransactions.isNotEmpty()) { + confirmedTransactions.sortedBy { it.confirmationBlock?.height }.forEach { + ConfirmedTransactionCard(it, navController) + } + } + } + } +} + +fun viewTransaction(navController: NavController, txid: String) { + navController.navigate(TransactionScreen(txid)) +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionScreen.kt new file mode 100644 index 0000000..2c2a8e5 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/TransactionScreen.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import org.bitcoindevkit.devkitwallet.presentation.navigation.RbfScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar + +@Composable +internal fun TransactionScreen(txid: String?, navController: NavController) { + val colorScheme = MaterialTheme.colorScheme + + Scaffold( + topBar = { + SecondaryScreensAppBar( + title = "Transaction Details", + navigation = { navController.navigateUp() }, + ) + }, + containerColor = colorScheme.surface, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(48.dp)) + + Text( + text = "Transaction", + color = colorScheme.onSurface, + fontSize = 28.sp, + fontFamily = inter, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.weight(1f)) + + TransactionDetailButton( + content = "increase fees", + navController = navController, + txid = txid, + ) + + Spacer(Modifier.height(32.dp)) + } + } +} + +@Composable +fun TransactionDetailButton(content: String, navController: NavController, txid: String?) { + val colorScheme = MaterialTheme.colorScheme + Button( + onClick = { + when (content) { + "increase fees" -> { + navController.navigate(RbfScreen(txid!!)) + } + + "back to transaction list" -> { + navController.navigateUp() + } + } + }, + colors = ButtonDefaults.buttonColors(colorScheme.secondary), + shape = RoundedCornerShape(16.dp), + modifier = + Modifier + .height(52.dp) + .fillMaxWidth(), + ) { + Text( + text = content, + fontSize = 14.sp, + fontFamily = inter, + textAlign = TextAlign.Center, + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/WalletHomeScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/WalletHomeScreen.kt new file mode 100644 index 0000000..72ceeeb --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/WalletHomeScreen.kt @@ -0,0 +1,414 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import com.composables.icons.lucide.ArrowDownLeft +import com.composables.icons.lucide.ArrowUpRight +import com.composables.icons.lucide.History +import com.composables.icons.lucide.List +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Monitor +import com.composables.icons.lucide.Settings +import com.composables.icons.lucide.Shield +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit +import org.bitcoindevkit.devkitwallet.domain.utils.formatInBtc +import org.bitcoindevkit.devkitwallet.presentation.navigation.BlockchainClientScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.ReceiveScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.RecoveryPhraseScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.SendScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.SettingsScreen +import org.bitcoindevkit.devkitwallet.presentation.navigation.TransactionHistoryScreen +import org.bitcoindevkit.devkitwallet.presentation.theme.DayGlowHistoryAccent +import org.bitcoindevkit.devkitwallet.presentation.theme.NightGlowSubtle +import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.CustomSnackbar +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState + +private const val TAG = "WalletHomeScreen" + +@Composable +internal fun WalletHomeScreen( + state: WalletScreenState, + onAction: (WalletScreenAction) -> Unit, + navController: NavHostController, +) { + val snackbarHostState = remember { SnackbarHostState() } + val networkAvailable: Boolean = isOnline(LocalContext.current) + val interactionSource = remember { MutableInteractionSource() } + val scope: CoroutineScope = rememberCoroutineScope() + val colorScheme = MaterialTheme.colorScheme + + LaunchedEffect(Unit) { + onAction(WalletScreenAction.UpdateBalance) + } + + Scaffold( + topBar = { WalletAppBar(onSettingsClick = { navController.navigate(SettingsScreen) }) }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) { data -> + CustomSnackbar(data) + } + }, + ) { paddingValues -> + + // If a new snackbar has be triggered, show it + state.snackbarMessage?.let { message -> + Log.i("WalletHomeScreen", "Showing snackbar: $message") + LaunchedEffect(message) { + scope.launch { + snackbarHostState.showSnackbar(message) + onAction(WalletScreenAction.ClearSnackbar) + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { onAction(WalletScreenAction.SwitchUnit) }, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(32.dp)) + + // Balance + when (state.unit) { + CurrencyUnit.Bitcoin -> { + Text( + text = state.balance.formatInBtc(), + fontFamily = inter, + // fontFamily = googleSansCode, + fontSize = 48.sp, + fontWeight = FontWeight.Light, + color = colorScheme.onSurface, + ) + } + + CurrencyUnit.Satoshi -> { + Text( + text = "${state.balance} sat", + fontFamily = inter, + // fontFamily = googleSansCode, + fontSize = 48.sp, + fontWeight = FontWeight.Light, + color = colorScheme.onSurface, + ) + } + } + Text( + text = "BITCOIN", + fontSize = 14.sp, + color = NightGlowSubtle, + letterSpacing = 2.sp, + ) + + Spacer(Modifier.height(24.dp)) + + // Receive / Send row + Row( + modifier = Modifier.fillMaxWidth(0.9f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Receive card + OutlinedCard( + onClick = { navController.navigate(ReceiveScreen) }, + modifier = Modifier + .weight(1f) + .height(120.dp), + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.5.dp, colorScheme.outline.copy(alpha = 0.15f)), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(16.dp)) + .background(colorScheme.onSurfaceVariant.copy(alpha = 0.08f)) + .border( + width = 1.dp, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.12f), + shape = RoundedCornerShape(16.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Lucide.ArrowDownLeft, + contentDescription = "Receive", + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = "RECEIVE", + color = colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ) + } + } + + // Send card + OutlinedCard( + onClick = { navController.navigate(SendScreen) }, + enabled = networkAvailable, + modifier = Modifier + .weight(1f) + .height(120.dp), + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.5.dp, colorScheme.outline.copy(alpha = 0.15f)), + colors = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(16.dp)) + .background(colorScheme.onSurfaceVariant.copy(alpha = 0.08f)) + .border( + width = 1.dp, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.12f), + shape = RoundedCornerShape(16.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Lucide.ArrowUpRight, + contentDescription = "Send", + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = "SEND", + color = colorScheme.onSurfaceVariant, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + thickness = 1.dp, + color = colorScheme.outline.copy(alpha = 0.08f), + modifier = Modifier.fillMaxWidth(0.9f), + ) + Spacer(Modifier.height(16.dp)) + + // Quick actions row + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QuickAction( + icon = Lucide.List, + label = "UTXOs", + tint = colorScheme.primary, + onClick = {}, + ) + QuickAction( + icon = Lucide.Shield, + label = "Security", + tint = colorScheme.secondary, + onClick = { navController.navigate(RecoveryPhraseScreen) }, + ) + QuickAction( + icon = Lucide.Monitor, + label = "Node", + tint = colorScheme.tertiary, + onClick = { navController.navigate(BlockchainClientScreen) }, + ) + QuickAction( + icon = Lucide.History, + label = "History", + tint = DayGlowHistoryAccent, + onClick = { navController.navigate(TransactionHistoryScreen) }, + ) + } + + // Network unavailable banner + if (!networkAvailable) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .background(color = colorScheme.primary.copy(alpha = 0.12f)) + .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = "Network unavailable", + fontFamily = inter, + fontSize = 14.sp, + color = colorScheme.onSurface, + ) + } + } + } + } +} + +@Composable +private fun QuickAction(icon: ImageVector, label: String, tint: Color, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + ) { + Box( + modifier = Modifier + .size(56.dp) + .border( + width = 1.5.dp, + color = tint.copy(alpha = 0.20f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = tint, + modifier = Modifier.size(22.dp), + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = label, + fontSize = 11.sp, + color = NightGlowSubtle, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WalletAppBar(onSettingsClick: () -> Unit) { + CenterAlignedTopAppBar( + title = { + Text( + text = "", + color = MaterialTheme.colorScheme.onSurface, + fontFamily = inter, + fontSize = 20.sp, + ) + }, + actions = { + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Lucide.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) +} + +fun isOnline(context: Context): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + if (capabilities != null) { + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR") + return true + } + + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI") + return true + } + + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> { + Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET") + return true + } + } + } + return false +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt new file mode 100644 index 0000000..e9e4a83 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/AddressViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import org.bitcoindevkit.AddressInfo +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ReceiveScreenState + +internal class AddressViewModel(private val wallet: Wallet) : ViewModel() { + var state: ReceiveScreenState by mutableStateOf(ReceiveScreenState()) + private set + + fun onAction(action: ReceiveScreenAction) { + when (action) { + is ReceiveScreenAction.UpdateAddress -> updateAddress() + } + } + + private fun updateAddress() { + val newAddress: AddressInfo = wallet.getNewAddress() + DwLogger.log(INFO, "Revealing new address at index ${newAddress.index}") + + state = + ReceiveScreenState( + address = newAddress.address.toString(), + addressIndex = newAddress.index, + ) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt new file mode 100644 index 0000000..3a0a345 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.bitcoindevkit.FeeRate +import org.bitcoindevkit.Psbt +import org.bitcoindevkit.devkitwallet.data.Kyoto +import org.bitcoindevkit.devkitwallet.data.KyotoNotInitialized +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.SendScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.TransactionType +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.TxDataBundle + +private const val TAG = "SendViewModel" + +internal class SendViewModel(private val wallet: Wallet) : ViewModel() { + fun onAction(action: SendScreenAction) { + when (action) { + is SendScreenAction.Broadcast -> broadcast(action.txDataBundle) + } + } + + private fun broadcast(txInfo: TxDataBundle) { + Log.i(TAG, "The tx data bundle is $txInfo") + + // TODO: Add error snackbar if Kyoto node is not running, or maybe simply disable the button + viewModelScope.launch { + try { + // Create, sign, and broadcast + val psbt: Psbt = + when (txInfo.transactionType) { + TransactionType.STANDARD -> { + wallet.createTransaction( + recipientList = txInfo.recipients, + feeRate = FeeRate.fromSatPerVb(txInfo.feeRate), + opReturnMsg = txInfo.opReturnMsg, + ) + } + + // TransactionType.SEND_ALL -> Wallet.createSendAllTransaction(recipientList[0].address, FeeRate.fromSatPerVb(feeRate), rbfEnabled, opReturnMsg) + TransactionType.SEND_ALL -> { + throw NotImplementedError("Send all not implemented") + } + } + val isSigned = wallet.sign(psbt) + if (isSigned) { + val transaction = psbt.extractTx() + val wtxid: String = Kyoto.getInstance().broadcast(transaction).toString() + Log.i(TAG, "Transaction was broadcast! txid: $wtxid") + } else { + Log.i(TAG, "Transaction not signed.") + } + } catch (e: KyotoNotInitialized) { + Log.i(TAG, "Kyoto was not initialized! Transaction cannot be broadcast.") + } catch (e: Throwable) { + Log.i(TAG, "Broadcast error message: ${e.message}") + } + } + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt new file mode 100644 index 0000000..4ebc587 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.bitcoindevkit.devkitwallet.data.Kyoto +import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit +import org.bitcoindevkit.devkitwallet.domain.DwLogger +import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO +import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState + +private const val TAG = "WalletViewModel" + +internal class WalletViewModel( + private val wallet: Wallet, +) : ViewModel() { + var state: WalletScreenState by mutableStateOf(WalletScreenState()) + private set + + private val kyotoCoroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO) + private var kyoto: Kyoto? = null + + @Suppress("ktlint:standard:no-multi-spaces") + fun onAction(action: WalletScreenAction) { + when (action) { + WalletScreenAction.SwitchUnit -> switchUnit() + WalletScreenAction.UpdateBalance -> updateBalance() + WalletScreenAction.ActivateCbfNode -> activateKyoto() + WalletScreenAction.StopKyotoNode -> stopKyotoNode() + WalletScreenAction.ClearSnackbar -> clearSnackbar() + } + } + + private fun showSnackbar(message: String) { + state = state.copy(snackbarMessage = message) + } + + private fun clearSnackbar() { + state = state.copy(snackbarMessage = null) + } + + private fun switchUnit() { + state = + when (state.unit) { + CurrencyUnit.Bitcoin -> state.copy(unit = CurrencyUnit.Satoshi) + CurrencyUnit.Satoshi -> state.copy(unit = CurrencyUnit.Bitcoin) + } + } + + private fun updateLatestBlock(blockHeight: UInt) { + state = state.copy(bestBlockHeight = blockHeight) + } + + private fun updateBalance() { + viewModelScope.launch(Dispatchers.IO) { + val newBalance = wallet.getBalance() + Log.i("Kyoto", "New balance: $newBalance") + DwLogger.log(INFO, "New balance: $newBalance") + + state = state.copy(balance = newBalance) + Log.i("Kyoto", "New state object: $state") + DwLogger.log(INFO, "New state object: $state") + } + } + + private fun activateKyoto() { + val dataDir = wallet.internalAppFilesPath + this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network) + val updatesFlow = kyoto!!.start() + kyotoCoroutineScope.launch { + updatesFlow.collect { + Log.i(TAG, "Collecting a flow update") + wallet.applyUpdate(it) + updateBalance() + updateBestBlock() + } + } + kyoto!!.logToLogcat() + } + + private fun stopKyotoNode() { + kyoto!!.shutdown() + } + + private fun updateBestBlock() { + val bestBlockHeight = wallet.bestBlock() + state = state.copy(bestBlockHeight = bestBlockHeight) + } +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviReceiveScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviReceiveScreen.kt new file mode 100644 index 0000000..c1fa731 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviReceiveScreen.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi + +data class ReceiveScreenState( + val address: String? = null, + val addressIndex: UInt? = null, +) + +sealed interface ReceiveScreenAction { + data object UpdateAddress : ReceiveScreenAction +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviSendScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviSendScreen.kt new file mode 100644 index 0000000..eee1809 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviSendScreen.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi + +// data class SendScreenState( +// val address: String? = null, +// ) + +sealed class SendScreenAction { + data class Broadcast(val txDataBundle: TxDataBundle) : SendScreenAction() +} + +data class TxDataBundle( + val recipients: List, + val feeRate: ULong, + val transactionType: TransactionType, + val rbfDisabled: Boolean = false, + val opReturnMsg: String? = null, +) + +data class Recipient(var address: String, var amount: ULong) + +enum class TransactionType { + STANDARD, + SEND_ALL, +} diff --git a/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt new file mode 100644 index 0000000..d2726a6 --- /dev/null +++ b/newbuildsystem/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE file. + */ + +package org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi + +import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit + +data class WalletScreenState( + val balance: ULong = 0u, + val unit: CurrencyUnit = CurrencyUnit.Bitcoin, + val bestBlockHeight: UInt = 0u, + val snackbarMessage: String? = null, + val kyotoNodeStatus: CbfNodeStatus = CbfNodeStatus.Stopped, +) + +sealed interface WalletScreenAction { + data object UpdateBalance : WalletScreenAction + + data object SwitchUnit : WalletScreenAction + + data object ActivateCbfNode : WalletScreenAction + + data object StopKyotoNode : WalletScreenAction + + data object ClearSnackbar : WalletScreenAction +} + +enum class CbfNodeStatus { + Running, + Stopped, +}