Merge pull request #1437 from cypherstack/julian/various-fixes-contd
What changed, and why it matters
This is a very large routine merge of 'various fixes' for the Stack Wallet Flutter app. The visible changes mostly update build tooling (Flutter 3.47.2, Android Gradle Plugin 9, Go/Rust versions), switch several crypto plugins from Git submodules to native-assets/prebuilt builds, reformat code, regenerate Isar database schema files, and fix a handful of UI/amount-handling bugs. There is no explicit security disclosure in the commit message or diff, and no single clearly exploitable vulnerability is introduced. The most security-relevant code-level changes are hardening touches: Android secure-storage options now disable reset-on-error and enable backup migration, ElectrumX ping now returns false on timeout instead of throwing, and MWEB peg-out UTXOs now enforce an extra maturity check before being considered spendable. However, the patch is huge (234 files, thousands of lines changed), so a complete security review is not possible from the supplied excerpt alone.
Treat this as a routine maintenance merge rather than an emergency security patch. Because the change set is enormous and touches build tooling, native plugins, database schemas, and financial UI code, reviewers should: (1) verify the new pinned prebuilt native-asset hashes/sources for flutter_libepiccash and flutter_libmwc match expected upstream releases; (2) run the expanded test suite (many new tests are included) and confirm no regressions in amount parsing, exchange quoting, or UTXO selection; (3) validate the Android secure-storage migration behavior on devices with backup/restore; (4) review the full diff for any additional security-relevant changes not visible in the excerpt; (5) if this is being backported or released, request a changelog/security note from the vendor because the commit title gives no indication of security fixes.
Security signals we found
Android secure storage options changed to resetOnError: false and migrateWithBackup: true
ElectrumX ping timeout now returns false instead of leaving a dangling future/throw
MWEB peg-out UTXOs now require an additional maturity block count before being spendable
Large amount-parsing refactor replacing Decimal.tryParse with locale-aware Amount.tryParseEditableAmount
Isar schema version bump across many generated files
Crypto plugins moved from submodules to pinned prebuilts/native-assets (supply-chain/build-integrity change)
CakePay order view copy button target changed from orderId to selected.address
Evidence from the diff
The merge commit ‘julian/various-fixes-contd’ is a broad maintenance/feature branch. Key technical changes observed: (1) Build/CI modernization: Flutter bumped from 3.44.9 to 3.47.2, AGP to 9.1.1, Gradle to 9.3.1, Go to 1.24.13, Android SDK build-tools/platforms updated, Dockerfile and GitHub Actions adjusted. (2) Crypto plugin packaging change: .gitmodules removed for flutter_libepiccash, flutter_libmwc, frostdart; CI now uses a -d flag to select pinned Epic/MWC prebuilts, frostdart builds via native-assets hook, and git_versions.dart stub generation is removed. (3) Isar schema regeneration across many generated .g.dart files (version bump 3.3.0-dev.2 -> 3.3.2). (4) Amount/locale handling refactor: new Amount.tryParseEditableAmount, AmountInputFormatter, and locale-aware formatting applied to buy, exchange, send, and coin-control views. (5) Database helper simplifications: deleteWalletBlockchainData, deleteAddressLabels, deleteTransactionNotes now use Isar’s deleteAll instead of manual pagination. (6) Security-adjacent fixes: FlutterSecureStorage configured with AndroidOptions(resetOnError: false, migrateWithBackup: true) in migration and main; ElectrumXClient.ping timeout now explicitly returns false; UTXO confirmation logic now also respects an mwebPegoutMaturity value stored in otherData. (7) UI/UX fixes: CakePay copy button now copies the selected payment address instead of the order ID, exchange extraId/refundExtraId state added, various formatting and null-safety improvements. No CVE, advisory, or researcher attribution is present in the supplied materials.
Changed components
Stack Wallet Flutter applicationAndroid secure storage (flutter_secure_storage)ElectrumX client RPC layerUTXO/coin-control and MWEB wallet logicExchange, buy, send, and CakePay UI flowsIsar local database schemasCI/CD build pipeline (GitHub Actions, Dockerfile, Android Gradle)Native crypto plugin packaging (epiccash, mwc, frostdart)Inspect captured patch +14011 / −9752
### .github/workflows/build.yaml
@@ -17,6 +17,8 @@ on:
required: true
default: '1'
+# Configure app uses -d to select pinned Epic/MWC prebuilts when included.
+# Other native-assets packages, including frostdart, still build from source.
jobs:
build-disclaimer:
@@ -76,19 +78,6 @@ jobs:
- name: Get dependencies
run: flutter pub get
- - name: Create git_versions.dart stubs
- run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- EPIC_TAG=$(git -C crypto_plugins/flutter_libepiccash describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
- MWC_TAG=$(git -C crypto_plugins/flutter_libmwc describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
-
- printf 'String getPluginVersion() => "%s";\n' "$EPIC_TAG" \
- > crypto_plugins/flutter_libepiccash/lib/git_versions.dart
- printf 'String getPluginVersion() => "%s";\n' "$MWC_TAG" \
- > crypto_plugins/flutter_libmwc/lib/git_versions.dart
-
- name: Decode secrets
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
@@ -153,19 +142,6 @@ jobs:
- name: Get dependencies
run: flutter pub get
- - name: Create git_versions.dart stubs
- run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- EPIC_TAG=$(git -C crypto_plugins/flutter_libepiccash describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
- MWC_TAG=$(git -C crypto_plugins/flutter_libmwc describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
-
- printf 'String getPluginVersion() => "%s";\n' "$EPIC_TAG" \
- > crypto_plugins/flutter_libepiccash/lib/git_versions.dart
- printf 'String getPluginVersion() => "%s";\n' "$MWC_TAG" \
- > crypto_plugins/flutter_libmwc/lib/git_versions.dart
-
- name: Decode secrets
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
@@ -254,7 +230,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -265,6 +241,8 @@ jobs:
run: flutter doctor -v
- name: Configure app
+ env:
+ MWEBD_FETCH: '1'
run: |
cd scripts
echo "yes" | ./build_app.sh \
@@ -294,36 +272,6 @@ jobs:
- name: Get dependencies
run: flutter pub get
- # Stack Wallet uses mwebd.exe as a subprocess on Windows, not the FFI
- # DLL, so we don't need libmwebd.dll. The upstream plugin's Windows
- # build path requires WSL, which the GitHub runner lacks.
- - name: Patch flutter_mwebd to skip Windows FFI build (CI workaround)
- run: |
- set -euo pipefail
- cache_root="$(cygpath -u "$LOCALAPPDATA")/Pub/Cache/hosted/pub.dev"
- plugin_dir=$(find "$cache_root" -maxdepth 1 -type d -name 'flutter_mwebd-*' -print -quit)
- if [ -z "$plugin_dir" ] || [ ! -f "$plugin_dir/pubspec.yaml" ]; then
- echo "::error::Could not locate flutter_mwebd in $cache_root"
- exit 1
- fi
- pubspec="$plugin_dir/pubspec.yaml"
- echo "Patching $pubspec"
- sed -i '/^ windows:$/,/^ ffiPlugin: true$/d' "$pubspec"
- flutter pub get
-
- - name: Create git_versions.dart stubs
- run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- EPIC_TAG=$(git -C crypto_plugins/flutter_libepiccash describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
- MWC_TAG=$(git -C crypto_plugins/flutter_libmwc describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
-
- printf 'String getPluginVersion() => "%s";\n' "$EPIC_TAG" \
- > crypto_plugins/flutter_libepiccash/lib/git_versions.dart
- printf 'String getPluginVersion() => "%s";\n' "$MWC_TAG" \
- > crypto_plugins/flutter_libmwc/lib/git_versions.dart
-
- name: Decode secrets
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
@@ -373,7 +321,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -391,19 +339,6 @@ jobs:
- name: Get dependencies
run: flutter pub get
- - name: Create git_versions.dart stubs
- run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- EPIC_TAG=$(git -C crypto_plugins/flutter_libepiccash describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
- MWC_TAG=$(git -C crypto_plugins/flutter_libmwc describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
-
- printf 'String getPluginVersion() => "%s";\n' "$EPIC_TAG" \
- > crypto_plugins/flutter_libepiccash/lib/git_versions.dart
- printf 'String getPluginVersion() => "%s";\n' "$MWC_TAG" \
- > crypto_plugins/flutter_libmwc/lib/git_versions.dart
-
- name: Decode secrets
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
@@ -455,7 +390,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -473,19 +408,6 @@ jobs:
- name: Get dependencies
run: flutter pub get
- - name: Create git_versions.dart stubs
- run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- EPIC_TAG=$(git -C crypto_plugins/flutter_libepiccash describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
- MWC_TAG=$(git -C crypto_plugins/flutter_libmwc describe --tags --exact-match HEAD 2>/dev/null || echo "dev")
-
- printf 'String getPluginVersion() => "%s";\n' "$EPIC_TAG" \
- > crypto_plugins/flutter_libepiccash/lib/git_versions.dart
- printf 'String getPluginVersion() => "%s";\n' "$MWC_TAG" \
- > crypto_plugins/flutter_libmwc/lib/git_versions.dart
-
- name: Decode secrets
env:
CHANGE_NOW: ${{ secrets.CHANGE_NOW }}
@@ -882,7 +804,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -971,7 +893,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -1046,7 +968,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -1340,7 +1262,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -1429,7 +1351,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
@@ -1506,7 +1428,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.9'
+ flutter-version: '3.47.2'
channel: 'stable'
- uses: actions/setup-go@v6
### .github/workflows/release-mwebd-windows.yaml
@@ -6,7 +6,7 @@ on:
mwebd_version:
description: 'mwebd tag to build (must match _mwebdVersion in tool/build_standalone_mwebd_windows.dart)'
required: true
- default: 'v0.1.8'
+ default: 'v0.1.19'
permissions:
contents: write
@@ -18,9 +18,9 @@ jobs:
run:
shell: bash
steps:
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
- go-version: '1.22'
+ go-version: '1.24.13'
- name: Clone mwebd
run: git clone https://github.com/ltcmweb/mwebd.git --branch "${{ inputs.mwebd_version }}" mwebd
### .github/workflows/test.yaml
@@ -18,26 +18,30 @@ jobs:
fetch-depth: 0
submodules: recursive
- - name: Configure app
+ # -d enables the pinned Epic/MWC native-assets prebuilts for flutter test.
+ - name: Configure app (prebuilt native assets)
run: |
cd scripts
echo "yes" | ./build_app.sh -v "0.0.1" -b "1" -p "linux" -a "stack_wallet" -d -s
- - name: Get dependencies
- run: flutter pub get
-
- - name: Create git_versions.dart stubs
+ - name: Check formatting of changed files
run: |
- mkdir -p crypto_plugins/flutter_libepiccash/lib
- mkdir -p crypto_plugins/flutter_libmwc/lib
-
- cat > crypto_plugins/flutter_libepiccash/lib/git_versions.dart << 'EOF'
- String getPluginVersion() => "stub-for-tests";
- EOF
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ BASE=$(git merge-base ${{ github.event.pull_request.base.sha }} HEAD)
+ else
+ BASE=${{ github.event.before }}
+ fi
+ FILES=$(git diff --name-only --diff-filter=ACM "$BASE"..HEAD -- '*.dart')
+ if [ -z "$FILES" ]; then
+ echo "No Dart files changed."
+ exit 0
+ fi
+ echo "Checking formatting of $(echo "$FILES" | wc -l) file(s):"
+ echo "$FILES"
+ dart format --output=none --set-exit-if-changed $FILES
- cat > crypto_plugins/flutter_libmwc/lib/git_versions.dart << 'EOF'
- String getPluginVersion() => "stub-for-tests";
- EOF
+ - name: Get dependencies
+ run: flutter pub get
- name: Decode secrets
env:
@@ -66,21 +70,6 @@ jobs:
run: bash prebuild.sh
working-directory: scripts
- - name: Check formatting of changed files
- run: |
- if [ "${{ github.event_name }}" = "pull_request" ]; then
- BASE=$(git merge-base ${{ github.event.pull_request.base.sha }} HEAD)
- else
- BASE=${{ github.event.before }}
- fi
- FILES=$(git diff --name-only --diff-filter=ACM "$BASE"..HEAD -- '*.dart')
- if [ -z "$FILES" ]; then
- echo "No Dart files changed."
- exit 0
- fi
- echo "Checking formatting of $(echo "$FILES" | wc -l) file(s):"
- echo "$FILES"
- dart format --output=none --set-exit-if-changed $FILES
# - name: Analyze
# run: flutter analyze
- name: Test
### .gitmodules
@@ -1,9 +0,0 @@
-[submodule "crypto_plugins/flutter_libepiccash"]
- path = crypto_plugins/flutter_libepiccash
- url = https://github.com/cypherstack/flutter_libepiccash.git
-[submodule "crypto_plugins/frostdart"]
- path = crypto_plugins/frostdart
- url = https://github.com/cypherstack/frostdart
-[submodule "crypto_plugins/flutter_libmwc"]
- path = crypto_plugins/flutter_libmwc
- url = https://github.com/cypherstack/flutter_libmwc
### Dockerfile
@@ -63,11 +63,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \
&& sdkmanager \
"platform-tools" \
"build-tools;35.0.0" \
+ "build-tools;36.0.0" \
+ "build-tools;37.0.0" \
"platforms;android-32" \
"platforms;android-33" \
"platforms;android-34" \
"platforms;android-35" \
"platforms;android-36" \
+ "platforms;android-37.0" \
"ndk;28.0.13004108" \
"ndk;28.2.13676358" \
"cmake;3.22.1" \
@@ -83,7 +86,7 @@ RUN curl -fsSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz
ENV FLUTTER_HOME=/opt/flutter \
PATH=/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:$PATH
-RUN git clone --depth 1 --branch 3.44.9 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
+RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
&& git config --global --add safe.directory '*' \
&& flutter config --no-analytics \
&& flutter precache --linux --android \
@@ -148,11 +151,14 @@ RUN mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" \
&& sdkmanager \
"platform-tools" \
"build-tools;35.0.0" \
+ "build-tools;36.0.0" \
+ "build-tools;37.0.0" \
"platforms;android-32" \
"platforms;android-33" \
"platforms;android-34" \
"platforms;android-35" \
"platforms;android-36" \
+ "platforms;android-37.0" \
"ndk;28.0.13004108" \
"ndk;28.2.13676358" \
"cmake;3.22.1" \
@@ -168,7 +174,7 @@ RUN curl -fsSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz
ENV FLUTTER_HOME=/opt/flutter \
PATH=/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:$PATH
-RUN git clone --depth 1 --branch 3.44.9 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
+RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
&& git config --global --add safe.directory '*' \
&& flutter config --no-analytics \
&& flutter precache --android \
@@ -179,7 +185,7 @@ RUN git config --system --add safe.directory '*'
RUN flutter --version && rustc --version && cargo --version && go version
-# Minimal image for flutter test (no Rust, no Android SDK, no cross-compilers)
+# Image for flutter test (no Android SDK or cross-compilers)
FROM ubuntu:24.04 AS test
ENV DEBIAN_FRONTEND=noninteractive \
@@ -197,15 +203,31 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libjsoncpp-dev liblzma-dev libsecret-1-dev libssl-dev \
&& rm -rf /var/lib/apt/lists/*
+# Epic/MWC use prebuilts; frostdart still compiles through its native-assets hook.
+ENV RUSTUP_HOME=/usr/local/rustup \
+ CARGO_HOME=/usr/local/cargo \
+ PATH=/usr/local/cargo/bin:$PATH
+
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
+ | sh -s -- -y --default-toolchain 1.90.0 --profile minimal --no-modify-path \
+ && chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
+
+ENV PATH=/usr/local/go/bin:$PATH
+
+RUN curl -fsSL https://go.dev/dl/go1.24.13.linux-amd64.tar.gz -o /tmp/go.tar.gz \
+ && echo "1fc94b57134d51669c72173ad5d49fd62afb0f1db9bf3f798fd98ee423f8d730 /tmp/go.tar.gz" | sha256sum -c \
+ && tar -C /usr/local -xzf /tmp/go.tar.gz \
+ && rm /tmp/go.tar.gz
+
ENV FLUTTER_HOME=/opt/flutter \
PATH=/opt/flutter/bin:/opt/flutter/bin/cache/dart-sdk/bin:$PATH
-RUN git clone --depth 1 --branch 3.44.9 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
+RUN git clone --depth 1 --branch 3.47.2 https://github.com/flutter/flutter.git "$FLUTTER_HOME" \
&& git config --global --add safe.directory '*' \
&& flutter config --no-analytics \
&& flutter precache --linux \
&& chmod -R a+rwX "$FLUTTER_HOME"
RUN git config --system --add safe.directory '*'
-RUN flutter --version
+RUN flutter --version && go version
### analysis_options.yaml
@@ -20,6 +20,12 @@ analyzer:
- "lib/generated_plugin_registrant.dart"
- "lib/wl_gen/generated/**"
- '**/*.template.dart'
+ - build/**
+ - android/**
+ - ios/**
+ - windows/**
+ - macos/**
+ - linux/**
# For more information see:
# https://dart.dev/guides/language/analysis-options#enabling-additional-type-checks
### android/gradle.properties
@@ -1,3 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
-android.enableJetifier=true
\ No newline at end of file
+# Jetifier removed in AGP 9; all deps are AndroidX already.
+# Preserve Flutter's legacy Kotlin and Android DSL compatibility on AGP 9.
+android.newDsl=false
+android.builtInKotlin=false
### android/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
### android/settings.gradle
@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
- id "com.android.application" version '8.11.1' apply false
- id "org.jetbrains.kotlin.android" version "2.2.20" apply false
+ id "com.android.application" version '9.1.1' apply false
+ id "org.jetbrains.kotlin.android" version "2.3.20" apply false
}
include ":app"
### crypto_plugins/flutter_libepiccash
@@ -1 +0,0 @@
-Subproject commit f4a55aa9e5b6066428402291ed228aa0dd921534
### crypto_plugins/flutter_libmwc
@@ -1 +0,0 @@
-Subproject commit c8db22aed2c50aa1e95dfc532abb0a4961c543d7
### crypto_plugins/frostdart
@@ -1 +0,0 @@
-Subproject commit 395765297a52c5f867ae6256636cf51e0ad20876
### docs/building.md
@@ -289,15 +289,13 @@ wsl
https://stackoverflow.com/questions/46610256/chmod-wsl-bash-doesnt-work/50856772#50856772
-Then build windows `dll` libraries by running the following script on the WSL2 Ubuntu 24.04 host:
+Then build `secp256k1.dll` by running the following script on the WSL2 Ubuntu 24.04 host:
-- `stack_wallet/scripts/windows/build_all.sh`
+- `stack_wallet/scripts/windows/build_secp256k1_wsl.sh`
-If the DLLs were built on the WSL filesystem instead of on Windows, copy the resulting `dll`s to their respective positions on the Windows host:
+The crypto plugin libraries (epiccash, mwc, frostdart) are native-assets packages and are built by their build hooks during `flutter build`; they no longer need to be built here.
-- `stack_wallet/crypto_plugins/flutter_libepiccash/scripts/windows/build/libepic_cash_wallet.dll`
-
-<!-- TODO: script the copying or installation of libraries from WSL2 to the parent Windows host -->
+If the DLL was built on the WSL filesystem instead of on Windows, copy `stack_wallet/build/secp256k1.dll` to the same position on the Windows host.
Frostdart will be built by the Windows host later.
@@ -356,15 +354,6 @@ cd .. // When finished go back to the root directory.
or manually by creating the files referenced in that script with the specified content.
-### Build frostdart
-
-In PowerShell on the Windows host, navigate to the `stack_wallet` folder:
-```
-cd crypto_plugins/frostdart/scripts/windows
-./build_all.bat
-cd .. // When finished go back to the root directory.
-```
-
### Running
Run the following commands:
### ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,59 @@
+{
+ "pins" : [
+ {
+ "identity" : "dkcamera",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKCamera",
+ "state" : {
+ "branch" : "master",
+ "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
+ }
+ },
+ {
+ "identity" : "dkimagepickercontroller",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKImagePickerController",
+ "state" : {
+ "branch" : "4.3.9",
+ "revision" : "0bdfeacefa308545adde07bef86e349186335915"
+ }
+ },
+ {
+ "identity" : "dkphotogallery",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKPhotoGallery",
+ "state" : {
+ "branch" : "master",
+ "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
+ }
+ },
+ {
+ "identity" : "sdwebimage",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SDWebImage/SDWebImage",
+ "state" : {
+ "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
+ "version" : "5.21.7"
+ }
+ },
+ {
+ "identity" : "swiftygif",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kirualex/SwiftyGif.git",
+ "state" : {
+ "revision" : "4430cbc148baa3907651d40562d96325426f409a",
+ "version" : "5.4.5"
+ }
+ },
+ {
+ "identity" : "tocropviewcontroller",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/TimOliver/TOCropViewController",
+ "state" : {
+ "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
+ "version" : "2.8.0"
+ }
+ }
+ ],
+ "version" : 2
+}
### ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
+ <PreActions>
+ <ExecutionAction
+ ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
+ <ActionContent
+ title = "Run Prepare Flutter Framework Script"
+ scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
+ <EnvironmentBuildable>
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "97C146ED1CF9000F007C117D"
+ BuildableName = "Runner.app"
+ BlueprintName = "Runner"
+ ReferencedContainer = "container:Runner.xcodeproj">
+ </BuildableReference>
+ </EnvironmentBuildable>
+ </ActionContent>
+ </ExecutionAction>
+ </PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
### ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,59 @@
+{
+ "pins" : [
+ {
+ "identity" : "dkcamera",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKCamera",
+ "state" : {
+ "branch" : "master",
+ "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
+ }
+ },
+ {
+ "identity" : "dkimagepickercontroller",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKImagePickerController",
+ "state" : {
+ "branch" : "4.3.9",
+ "revision" : "0bdfeacefa308545adde07bef86e349186335915"
+ }
+ },
+ {
+ "identity" : "dkphotogallery",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zhangao0086/DKPhotoGallery",
+ "state" : {
+ "branch" : "master",
+ "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
+ }
+ },
+ {
+ "identity" : "sdwebimage",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SDWebImage/SDWebImage",
+ "state" : {
+ "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
+ "version" : "5.21.7"
+ }
+ },
+ {
+ "identity" : "swiftygif",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/kirualex/SwiftyGif.git",
+ "state" : {
+ "revision" : "4430cbc148baa3907651d40562d96325426f409a",
+ "version" : "5.4.5"
+ }
+ },
+ {
+ "identity" : "tocropviewcontroller",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/TimOliver/TOCropViewController",
+ "state" : {
+ "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
+ "version" : "2.8.0"
+ }
+ }
+ ],
+ "version" : 2
+}
### lib/app_config.dart
@@ -42,7 +42,7 @@ abstract class AppConfig {
}
static CryptoCurrency? getCryptoCurrencyForTicker(
- final String ticker, {
+ String ticker, {
bool caseInsensitive = true,
}) {
final _ticker = caseInsensitive ? ticker.toLowerCase() : ticker;
@@ -76,7 +76,7 @@ abstract class AppConfig {
/// Fuzzy logic. Use with caution!!
@Deprecated("dangerous")
- static CryptoCurrency getCryptoCurrencyByPrettyName(final String prettyName) {
+ static CryptoCurrency getCryptoCurrencyByPrettyName(String prettyName) {
// trocador hack
const hackSplitter = " (Mainnet";
final name = prettyName.contains(hackSplitter)
### lib/db/isar/main_db.dart
@@ -10,7 +10,6 @@
import 'dart:io';
-import 'package:decimal/decimal.dart';
import 'package:isar_community/isar.dart';
import 'package:tuple/tuple.dart';
@@ -349,6 +348,7 @@ class MainDB {
blockTime: utxo.blockTime,
blockHeight: utxo.blockHeight,
blockHash: utxo.blockHash,
+ otherData: utxo.otherData,
// passing null keeps the stored value
isBlocked: applyAutoBlock ? true : null,
blockedReason: applyAutoBlock ? utxo.blockedReason : null,
@@ -463,52 +463,14 @@ class MainDB {
//
Future<void> deleteWalletBlockchainData(String walletId) async {
await isar.writeTxn(() async {
- final transactionCount = await getTransactions(walletId).count();
- final transactionCountV2 = await isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .count();
- final addressCount = await getAddresses(walletId).count();
- final utxoCount = await getUTXOs(walletId).count();
-
- const paginateLimit = 100;
-
// transactions
- for (int i = 0; i < transactionCount; i += paginateLimit) {
- final txnIds = await getTransactions(
- walletId,
- ).offset(i).limit(paginateLimit).idProperty().findAll();
- await isar.transactions.deleteAll(txnIds);
- }
-
+ await getTransactions(walletId).deleteAll();
// transactions V2
- for (int i = 0; i < transactionCountV2; i += paginateLimit) {
- final txnIds = await isar.transactionV2s
- .where()
- .walletIdEqualTo(walletId)
- .offset(i)
- .limit(paginateLimit)
- .idProperty()
- .findAll();
- await isar.transactionV2s.deleteAll(txnIds);
- }
-
+ await isar.transactionV2s.where().walletIdEqualTo(walletId).deleteAll();
// addresses
- for (int i = 0; i < addressCount; i += paginateLimit) {
- final addressIds = await getAddresses(
- walletId,
- ).offset(i).limit(paginateLimit).idProperty().findAll();
- await isar.addresses.deleteAll(addressIds);
- }
-
+ await getAddresses(walletId).deleteAll();
// utxos
- for (int i = 0; i < utxoCount; i += paginateLimit) {
- final utxoIds = await getUTXOs(
- walletId,
- ).offset(i).limit(paginateLimit).idProperty().findAll();
- await isar.utxos.deleteAll(utxoIds);
- }
-
+ await getUTXOs(walletId).deleteAll();
// spark coins
await isar.sparkCoins
.where()
@@ -518,28 +480,14 @@ class MainDB {
}
Future<void> deleteAddressLabels(String walletId) async {
- final addressLabelCount = await getAddressLabels(walletId).count();
await isar.writeTxn(() async {
- const paginateLimit = 50;
- for (int i = 0; i < addressLabelCount; i += paginateLimit) {
- final labelIds = await getAddressLabels(
- walletId,
- ).offset(i).limit(paginateLimit).idProperty().findAll();
- await isar.addressLabels.deleteAll(labelIds);
- }
+ await getAddressLabels(walletId).deleteAll();
});
}
Future<void> deleteTransactionNotes(String walletId) async {
- final noteCount = await getTransactionNotes(walletId).count();
await isar.writeTxn(() async {
- const paginateLimit = 50;
- for (int i = 0; i < noteCount; i += paginateLimit) {
- final labelIds = await getTransactionNotes(
- walletId,
- ).offset(i).limit(paginateLimit).idProperty().findAll();
- await isar.transactionNotes.deleteAll(labelIds);
- }
+ await getTransactionNotes(walletId).deleteAll();
});
}
### lib/db/queries/queries.dart
@@ -42,12 +42,11 @@ extension MainDBQueries on MainDB {
required CCFilter filter,
required CCSortDescriptor sort,
required String searchTerm,
+ required String locale,
required CryptoCurrency cryptoCurrency,
}) {
var preSort = getUTXOs(walletId).filter().group((q) {
- final qq = q.group(
- (q) => q.usedIsNull().or().usedEqualTo(false),
- );
+ final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false));
switch (filter) {
case CCFilter.frozen:
return qq.and().isBlockedEqualTo(true);
@@ -59,39 +58,36 @@ extension MainDBQueries on MainDB {
});
if (searchTerm.isNotEmpty) {
- preSort = preSort.and().group(
- (q) {
- var qq = q.addressContains(searchTerm, caseSensitive: false);
-
- qq = qq.or().nameContains(searchTerm, caseSensitive: false);
- qq = qq.or().group(
- (q) => q
- .isBlockedEqualTo(true)
- .and()
- .blockedReasonContains(searchTerm, caseSensitive: false),
- );
-
- qq = qq.or().txidContains(searchTerm, caseSensitive: false);
- qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
-
- final maybeDecimal = Decimal.tryParse(searchTerm);
- if (maybeDecimal != null) {
- qq = qq.or().valueEqualTo(
- Amount.fromDecimal(
- maybeDecimal,
- fractionDigits: cryptoCurrency.fractionDigits,
- ).raw.toInt(),
- );
- }
-
- final maybeInt = int.tryParse(searchTerm);
- if (maybeInt != null) {
- qq = qq.or().valueEqualTo(maybeInt);
- }
-
- return qq;
- },
- );
+ preSort = preSort.and().group((q) {
+ var qq = q.addressContains(searchTerm, caseSensitive: false);
+
+ qq = qq.or().nameContains(searchTerm, caseSensitive: false);
+ qq = qq.or().group(
+ (q) => q
+ .isBlockedEqualTo(true)
+ .and()
+ .blockedReasonContains(searchTerm, caseSensitive: false),
+ );
+
+ qq = qq.or().txidContains(searchTerm, caseSensitive: false);
+ qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
+
+ final maybeAmount = Amount.tryParseEditableAmount(
+ searchTerm,
+ locale: locale,
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+ if (maybeAmount != null) {
+ qq = qq.or().valueEqualTo(maybeAmount.raw.toInt());
+ }
+
+ final maybeInt = int.tryParse(searchTerm);
+ if (maybeInt != null) {
+ qq = qq.or().valueEqualTo(maybeInt);
+ }
+
+ return qq;
+ });
}
final List<Id> ids;
@@ -114,12 +110,11 @@ extension MainDBQueries on MainDB {
required CCFilter filter,
required CCSortDescriptor sort,
required String searchTerm,
+ required String locale,
required CryptoCurrency cryptoCurrency,
}) {
var preSort = getUTXOs(walletId).filter().group((q) {
- final qq = q.group(
- (q) => q.usedIsNull().or().usedEqualTo(false),
- );
+ final qq = q.group((q) => q.usedIsNull().or().usedEqualTo(false));
switch (filter) {
case CCFilter.frozen:
return qq.and().isBlockedEqualTo(true);
@@ -131,39 +126,36 @@ extension MainDBQueries on MainDB {
});
if (searchTerm.isNotEmpty) {
- preSort = preSort.and().group(
- (q) {
- var qq = q.addressContains(searchTerm, caseSensitive: false);
-
- qq = qq.or().nameContains(searchTerm, caseSensitive: false);
- qq = qq.or().group(
- (q) => q
- .isBlockedEqualTo(true)
- .and()
- .blockedReasonContains(searchTerm, caseSensitive: false),
- );
-
- qq = qq.or().txidContains(searchTerm, caseSensitive: false);
- qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
-
- final maybeDecimal = Decimal.tryParse(searchTerm);
- if (maybeDecimal != null) {
- qq = qq.or().valueEqualTo(
- Amount.fromDecimal(
- maybeDecimal,
- fractionDigits: cryptoCurrency.fractionDigits,
- ).raw.toInt(),
- );
- }
-
- final maybeInt = int.tryParse(searchTerm);
- if (maybeInt != null) {
- qq = qq.or().valueEqualTo(maybeInt);
- }
-
- return qq;
- },
- );
+ preSort = preSort.and().group((q) {
+ var qq = q.addressContains(searchTerm, caseSensitive: false);
+
+ qq = qq.or().nameContains(searchTerm, caseSensitive: false);
+ qq = qq.or().group(
+ (q) => q
+ .isBlockedEqualTo(true)
+ .and()
+ .blockedReasonContains(searchTerm, caseSensitive: false),
+ );
+
+ qq = qq.or().txidContains(searchTerm, caseSensitive: false);
+ qq = qq.or().blockHashContains(searchTerm, caseSensitive: false);
+
+ final maybeAmount = Amount.tryParseEditableAmount(
+ searchTerm,
+ locale: locale,
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+ if (maybeAmount != null) {
+ qq = qq.or().valueEqualTo(maybeAmount.raw.toInt());
+ }
+
+ final maybeInt = int.tryParse(searchTerm);
+ if (maybeInt != null) {
+ qq = qq.or().valueEqualTo(maybeInt);
+ }
+
+ return qq;
+ });
}
final List<UTXO> utxos;
### lib/db/special_migrations.dart
@@ -43,7 +43,9 @@ abstract class CampfireMigration {
final myHive = HiveImpl();
myHive.init(appDirectory.path);
_wallets = await myHive.openBox<dynamic>('wallets');
- _secureStore = const FlutterSecureStorage();
+ _secureStore = const FlutterSecureStorage(
+ aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true),
+ );
} else {
await setDidRun();
}
### lib/db/sqlite/firo_cache_writer.dart
@@ -52,7 +52,7 @@ FCResult _updateSparkUsedTagsWith(Database db, List<List<dynamic>> tags) {
/// returns true if successful, otherwise false
FCResult _updateSparkAnonSetCoinsWith(
Database db,
- final List<RawSparkCoin> coinsRaw,
+ List<RawSparkCoin> coinsRaw,
SparkAnonymitySetMeta meta,
) {
if (coinsRaw.isEmpty) {
### lib/electrumx_rpc/electrumx_client.dart
@@ -210,8 +210,9 @@ class ElectrumXClient {
Future<bool> _allow() async {
if (_prefs.wifiOnly) {
- return (await Connectivity().checkConnectivity()) ==
- ConnectivityResult.wifi;
+ return (await Connectivity().checkConnectivity()).contains(
+ ConnectivityResult.wifi,
+ );
}
return true;
}
@@ -519,7 +520,11 @@ class ElectrumXClient {
/// Ping the server to ensure it is responding
///
/// Returns true if ping succeeded
- Future<bool> ping({String? requestID, int retryCount = 1}) async {
+ Future<bool> ping({
+ String? requestID,
+ int retryCount = 1,
+ Duration timeout = const Duration(seconds: 30),
+ }) async {
try {
// This doesn't work because electrum_adapter only returns the result:
// (which is always `null`).
@@ -535,14 +540,15 @@ class ElectrumXClient {
return await request(
requestID: requestID,
command: 'server.ping',
- requestTimeout: const Duration(seconds: 30),
+ requestTimeout: timeout,
retries: retryCount,
).timeout(
- const Duration(seconds: 30),
+ timeout,
onTimeout: () {
Logging.instance.d(
"ElectrumxClient.ping timed out with retryCount=$retryCount, host=$_host",
);
+ return false;
},
)
as bool;
@@ -1076,9 +1082,9 @@ class ElectrumXClient {
command: "spark.getmempoolsparktxids",
);
- final txids = List<String>.from(
- response as List,
- ).map((e) => e.toHexReversedFromBase64).toSet();
+ final txids = List<String>.from(response as List)
+ .map((e) => e.toHexReversedFromBase64)
+ .toSet();
Logging.instance.d(
"Finished ElectrumXClient.getMempoolTxids(). "
@@ -1289,8 +1295,7 @@ class ElectrumXClient {
required int endIndex, // exclusive
}) async {
try {
- const command =
- "spark.getsparkanonymitysetsector"; // TODO verify this will be correct
+ const command = "spark.getsparkanonymitysetsector"; // TODO verify this will be correct
final start = DateTime.now();
final response = await request(
requestID: requestID,
### lib/main.dart
@@ -24,7 +24,6 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
import 'package:logger/logger.dart';
import 'package:mobile_app_privacy/mobile_app_privacy.dart';
-import 'package:path_provider/path_provider.dart';
import 'package:window_size/window_size.dart';
import 'app_config.dart';
@@ -104,7 +103,7 @@ void main(List<String> args) async {
GoogleFonts.config.allowRuntimeFetching = false;
if (Platform.isIOS) {
- Util.libraryPath = await getLibraryDirectory();
+ Util.isIpad = await Util.getIsIPad;
}
Screen? screen;
if (Platform.isLinux || (Util.isDesktop && !Platform.isIOS)) {
@@ -293,17 +292,21 @@ void main(List<String> args) async {
if (!Util.isDesktop) {
final int dbVersion =
DB.instance.get<dynamic>(
- boxName: DB.boxNameDBInfo,
- key: "hive_data_version",
- )
- as int? ??
+ boxName: DB.boxNameDBInfo,
+ key: "hive_data_version",
+ ) as int? ??
0;
if (dbVersion < Constants.currentDataVersion) {
try {
await DbVersionMigrator().migrate(
dbVersion,
secureStore: const SecureStorageWrapper(
- store: FlutterSecureStorage(),
+ store: FlutterSecureStorage(
+ aOptions: AndroidOptions(
+ resetOnError: false,
+ migrateWithBackup: true,
+ ),
+ ),
isDesktop: false,
),
);
@@ -722,16 +725,16 @@ class _MaterialAppWithThemeState extends ConsumerState<MaterialAppWithTheme>
Future<void> goToRestoreSWB(String encrypted) async {
if (!ref.read(prefsChangeNotifierProvider).hasPin) {
- await Navigator.of(
- ref.read(pNavKey).currentContext!,
- ).pushNamed(CreatePinView.routeName, arguments: true).then((value) {
- if (value is! bool || value == false) {
- Navigator.of(ref.read(pNavKey).currentContext!).pushNamed(
- RestoreFromEncryptedStringView.routeName,
- arguments: encrypted,
- );
- }
- });
+ await Navigator.of(ref.read(pNavKey).currentContext!)
+ .pushNamed(CreatePinView.routeName, arguments: true)
+ .then((value) {
+ if (value is! bool || value == false) {
+ Navigator.of(ref.read(pNavKey).currentContext!).pushNamed(
+ RestoreFromEncryptedStringView.routeName,
+ arguments: encrypted,
+ );
+ }
+ });
} else {
unawaited(
Navigator.push(
### lib/models/exchange/incomplete_exchange.dart
@@ -28,6 +28,10 @@ class IncompleteExchangeModel extends ChangeNotifier {
final Decimal sendAmount;
final Decimal receiveAmount;
+ String get payInAmount => trade?.payInAmount ?? sendAmount.toString();
+
+ Decimal? get payInDecimal => Decimal.tryParse(payInAmount);
+
final ExchangeRateType rateType;
final bool reversed;
@@ -55,6 +59,28 @@ class IncompleteExchangeModel extends ChangeNotifier {
}
}
+ String? _extraId;
+
+ String? get extraId => _extraId;
+
+ set extraId(String? extraId) {
+ if (_extraId != extraId) {
+ _extraId = extraId;
+ notifyListeners();
+ }
+ }
+
+ String? _refundExtraId;
+
+ String? get refundExtraId => _refundExtraId;
+
+ set refundExtraId(String? refundExtraId) {
+ if (_refundExtraId != refundExtraId) {
+ _refundExtraId = refundExtraId;
+ notifyListeners();
+ }
+ }
+
Estimate? _estimate;
Estimate? get estimate => _estimate;
### lib/models/isar/exchange_cache/currency.g.dart
@@ -117,7 +117,7 @@ const CurrencySchema = CollectionSchema(
getId: _currencyGetId,
getLinks: _currencyGetLinks,
attach: _currencyAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _currencyEstimateSize(
### lib/models/isar/exchange_cache/pair.g.dart
@@ -82,7 +82,7 @@ const PairSchema = CollectionSchema(
getId: _pairGetId,
getLinks: _pairGetLinks,
attach: _pairAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _pairEstimateSize(
### lib/models/isar/models/address_label.g.dart
@@ -75,7 +75,7 @@ const AddressLabelSchema = CollectionSchema(
getId: _addressLabelGetId,
getLinks: _addressLabelGetLinks,
attach: _addressLabelAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _addressLabelEstimateSize(
### lib/models/isar/models/block_explorer.g.dart
@@ -48,7 +48,7 @@ const TransactionBlockExplorerSchema = CollectionSchema(
getId: _transactionBlockExplorerGetId,
getLinks: _transactionBlockExplorerGetLinks,
attach: _transactionBlockExplorerAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _transactionBlockExplorerEstimateSize(
### lib/models/isar/models/blockchain_data/address.g.dart
@@ -128,7 +128,7 @@ const AddressSchema = CollectionSchema(
getId: _addressGetId,
getLinks: _addressGetLinks,
attach: _addressAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _addressEstimateSize(
@@ -223,11 +223,10 @@ P _addressDeserializeProp<P>(
return (reader.readLong(offset)) as P;
case 1:
return (reader.readObjectOrNull<DerivationPath>(
- offset,
- DerivationPathSchema.deserialize,
- allOffsets,
- ))
- as P;
+ offset,
+ DerivationPathSchema.deserialize,
+ allOffsets,
+ )) as P;
case 2:
return (reader.readStringOrNull(offset)) as P;
case 3:
### lib/models/isar/models/blockchain_data/transaction.g.dart
@@ -151,7 +151,7 @@ const TransactionSchema = CollectionSchema(
getId: _transactionGetId,
getLinks: _transactionGetLinks,
attach: _transactionAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _transactionEstimateSize(
### lib/models/isar/models/blockchain_data/utxo.dart
@@ -86,12 +86,10 @@ class UTXO {
int? overrideMinConfirms, // added to handle namecoin name op outputs
}) {
final confirmations = getConfirmations(currentChainHeight);
-
- if (overrideMinConfirms != null) {
- return confirmations >= overrideMinConfirms;
- }
- return confirmations >=
+ final requiredConfirmations =
+ overrideMinConfirms ??
(isCoinbase ? minimumCoinbaseConfirms : minimumConfirms);
+ return confirmations >= max(requiredConfirmations, mwebPegoutMaturity ?? 0);
}
/// A lingering [blockedReason] on an unblocked utxo means the wallet
@@ -107,6 +105,24 @@ class UTXO {
return keyImage != null;
}
+ @ignore
+ int? get mwebPegoutMaturity {
+ if (otherData == null) {
+ return null;
+ }
+
+ try {
+ final value =
+ (jsonDecode(otherData!) as Map)[UTXOOtherDataKeys.mwebPegoutMaturity];
+ return value is int && value > 0 ? value : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ @ignore
+ bool get isMwebPegout => mwebPegoutMaturity != null;
+
@ignore
String? get keyImage {
if (otherData == null) {
@@ -189,6 +205,7 @@ class UTXO {
abstract final class UTXOOtherDataKeys {
static const keyImage = "keyImage";
+ static const mwebPegoutMaturity = "mwebPegoutMaturity";
static const spent = "spent";
static const nameOpData = "nameOpData";
}
### lib/models/isar/models/blockchain_data/utxo.g.dart
@@ -127,7 +127,7 @@ const UTXOSchema = CollectionSchema(
getId: _uTXOGetId,
getLinks: _uTXOGetLinks,
attach: _uTXOAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _uTXOEstimateSize(
### lib/models/isar/models/blockchain_data/v2/transaction_v2.g.dart
@@ -157,7 +157,7 @@ const TransactionV2Schema = CollectionSchema(
getId: _transactionV2GetId,
getLinks: _transactionV2GetLinks,
attach: _transactionV2Attach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _transactionV2EstimateSize(
### lib/models/isar/models/contact_entry.g.dart
@@ -68,7 +68,7 @@ const ContactEntrySchema = CollectionSchema(
getId: _contactEntryGetId,
getLinks: _contactEntryGetLinks,
attach: _contactEntryAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _contactEntryEstimateSize(
### lib/models/isar/models/encrypted_string_value.g.dart
@@ -48,7 +48,7 @@ const EncryptedStringValueSchema = CollectionSchema(
getId: _encryptedStringValueGetId,
getLinks: _encryptedStringValueGetLinks,
attach: _encryptedStringValueAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _encryptedStringValueEstimateSize(
### lib/models/isar/models/ethereum/eth_contract.g.dart
@@ -56,7 +56,7 @@ const EthContractSchema = CollectionSchema(
getId: _ethContractGetId,
getLinks: _ethContractGetLinks,
attach: _ethContractAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _ethContractEstimateSize(
### lib/models/isar/models/log.g.dart
@@ -57,7 +57,7 @@ const LogSchema = CollectionSchema(
getId: _logGetId,
getLinks: _logGetLinks,
attach: _logAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _logEstimateSize(
### lib/models/isar/models/sent_to_address.g.dart
@@ -71,7 +71,7 @@ const SentToAddressSchema = CollectionSchema(
getId: _sentToAddressGetId,
getLinks: _sentToAddressGetLinks,
attach: _sentToAddressAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _sentToAddressEstimateSize(
### lib/models/isar/models/solana/sol_contract.g.dart
@@ -55,7 +55,7 @@ const SolContractSchema = CollectionSchema(
getId: _solContractGetId,
getLinks: _solContractGetLinks,
attach: _solContractAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _solContractEstimateSize(
### lib/models/isar/models/transaction_note.g.dart
@@ -70,7 +70,7 @@ const TransactionNoteSchema = CollectionSchema(
getId: _transactionNoteGetId,
getLinks: _transactionNoteGetLinks,
attach: _transactionNoteAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _transactionNoteEstimateSize(
### lib/models/isar/ordinal.g.dart
@@ -77,7 +77,7 @@ const OrdinalSchema = CollectionSchema(
getId: _ordinalGetId,
getLinks: _ordinalGetLinks,
attach: _ordinalAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _ordinalEstimateSize(
### lib/models/isar/stack_theme.g.dart
@@ -857,7 +857,7 @@ const StackThemeSchema = CollectionSchema(
getId: _stackThemeGetId,
getLinks: _stackThemeGetLinks,
attach: _stackThemeAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _stackThemeEstimateSize(
@@ -1315,18 +1315,16 @@ P _stackThemeDeserializeProp<P>(
return (reader.readLong(offset)) as P;
case 6:
return (reader.readObjectOrNull<ThemeAssets>(
- offset,
- ThemeAssetsSchema.deserialize,
- allOffsets,
- ))
- as P;
+ offset,
+ ThemeAssetsSchema.deserialize,
+ allOffsets,
+ )) as P;
case 7:
return (reader.readObjectOrNull<ThemeAssetsV2>(
- offset,
- ThemeAssetsV2Schema.deserialize,
- allOffsets,
- ))
- as P;
+ offset,
+ ThemeAssetsV2Schema.deserialize,
+ allOffsets,
+ )) as P;
case 8:
return (reader.readLong(offset)) as P;
case 9:
@@ -1635,11 +1633,10 @@ P _stackThemeDeserializeProp<P>(
return (reader.readLong(offset)) as P;
case 161:
return (reader.readObjectOrNull<ThemeAssetsV3>(
- offset,
- ThemeAssetsV3Schema.deserialize,
- allOffsets,
- ))
- as P;
+ offset,
+ ThemeAssetsV3Schema.deserialize,
+ allOffsets,
+ )) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
### lib/models/node_model.dart
@@ -69,6 +69,54 @@ class NodeModel {
this.nodeApiSecret,
});
+ factory NodeModel.fromStackBackup(
+ Map<String, dynamic> map, {
+ Set<String>? legacyPrimaryNodeIds,
+ }) {
+ final id = map['id'] as String;
+ return NodeModel(
+ host: map['host'] as String,
+ port: map['port'] as int,
+ name: map['name'] as String,
+ id: id,
+ useSSL: _backupBool(map['useSSL'], fallback: true),
+ loginName: map['loginName'] as String?,
+ enabled: _backupBool(map['enabled'], fallback: true),
+ coinName: map['coinName'] as String,
+ isFailover: _backupBool(map['isFailover'], fallback: false),
+ isDown: _backupBool(map['isDown'], fallback: false),
+ trusted: _nullableBackupBool(map['trusted']),
+ torEnabled: _backupBool(map['torEnabled'], fallback: true),
+ clearnetEnabled: _backupBool(
+ map['clearEnabled'] ?? map['plainEnabled'],
+ fallback: true,
+ ),
+ forceNoTor: _backupBool(map['forceNoTor'], fallback: false),
+ isPrimary: _backupBool(
+ map['isPrimary'],
+ fallback: legacyPrimaryNodeIds?.contains(id) ?? false,
+ ),
+ nodeApiSecret: map['nodeApiSecret'] as String?,
+ );
+ }
+
+ static bool _backupBool(Object? value, {required bool fallback}) =>
+ _nullableBackupBool(value) ?? fallback;
+
+ static bool? _nullableBackupBool(Object? value) {
+ if (value is bool) {
+ return value;
+ }
+ if (value is String) {
+ return switch (value.trim().toLowerCase()) {
+ 'true' => true,
+ 'false' => false,
+ _ => null,
+ };
+ }
+ return null;
+ }
+
NodeModel copyWith({
String? host,
int? port,
### lib/pages/buy_view/buy_form.dart
@@ -31,6 +31,9 @@ import '../../services/buy/buy_response.dart';
import '../../services/buy/simplex/simplex_api.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
+import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/assets.dart';
import '../../utilities/barcode_scanner_interface.dart';
import '../../utilities/clipboard_interface.dart';
@@ -125,6 +128,13 @@ class _BuyFormState extends ConsumerState<BuyForm> {
// static String boundedCryptoTicker = '';
String _amountOutOfRangeErrorString = "";
+
+ Decimal? get _parsedBuyAmount => Amount.tryParseEditableAmount(
+ _buyAmountController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ fractionDigits: buyWithFiat ? 2 : 30,
+ )?.decimal;
+
void validateAmount() {
if (_buyAmountController.text.isEmpty) {
setState(() {
@@ -133,20 +143,30 @@ class _BuyFormState extends ConsumerState<BuyForm> {
return;
}
- final value = Decimal.tryParse(_buyAmountController.text);
+ final value = _parsedBuyAmount;
if (value == null) {
setState(() {
_amountOutOfRangeErrorString = "Invalid amount";
});
} else if (value > maxFiat && buyWithFiat) {
+ final locale = ref.read(localeServiceChangeNotifierProvider).locale;
+ final maximum = Amount.formatFixedDecimal(
+ maxFiat,
+ fractionDigits: 2,
+ locale: locale,
+ );
setState(() {
- _amountOutOfRangeErrorString =
- "Maximum amount: ${maxFiat.toStringAsFixed(2)}";
+ _amountOutOfRangeErrorString = "Maximum amount: $maximum";
});
} else if (value < minFiat && buyWithFiat) {
+ final locale = ref.read(localeServiceChangeNotifierProvider).locale;
+ final minimum = Amount.formatFixedDecimal(
+ minFiat,
+ fractionDigits: 2,
+ locale: locale,
+ );
setState(() {
- _amountOutOfRangeErrorString =
- "Minimum amount: ${minFiat.toStringAsFixed(2)}";
+ _amountOutOfRangeErrorString = "Minimum amount: $minimum";
});
} else {
setState(() {
@@ -237,9 +257,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Expanded(
child: RoundedWhiteContainer(
padding: const EdgeInsets.all(16),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: CryptoSelectionView(coins: coins),
),
),
@@ -363,9 +383,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Expanded(
child: RoundedWhiteContainer(
padding: const EdgeInsets.all(16),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: FiatSelectionView(fiats: fiats),
),
),
@@ -396,6 +416,11 @@ class _BuyFormState extends ConsumerState<BuyForm> {
// }
Future<void> previewQuote(SimplexQuote quote) async {
+ final buyAmount = _parsedBuyAmount;
+ if (buyAmount == null) {
+ return;
+ }
+
bool shouldPop = false;
unawaited(
showDialog(
@@ -414,11 +439,11 @@ class _BuyFormState extends ConsumerState<BuyForm> {
crypto: selectedCrypto!,
fiat: selectedFiat!,
youPayFiatPrice: buyWithFiat
- ? Decimal.parse(_buyAmountController.text)
+ ? buyAmount
: Decimal.parse("100"), // dummy value
youReceiveCryptoAmount: buyWithFiat
? Decimal.parse("0.000420282") // dummy value
- : Decimal.parse(_buyAmountController.text), // Ternary for this
+ : buyAmount,
id: "id", // anything; we get an ID back
receivingAddress: _receiveAddressController.text,
buyWithFiat: buyWithFiat,
@@ -491,9 +516,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -577,9 +602,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -651,9 +676,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Expanded(
child: RoundedWhiteContainer(
padding: const EdgeInsets.all(16),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: BuyQuotePreviewView(quote: quote),
),
),
@@ -795,8 +820,23 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
- final Locale locale = Localizations.localeOf(context);
- final format = NumberFormat.simpleCurrency(locale: locale.toString());
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_buyAmountController],
+ onRelocalized: validateAmount,
+ );
+ // intl throws ArgumentError for device locales it has no data for
+ // (e.g. yo, ig, mi); this is the raw device locale, not one resolved
+ // against supported locales.
+ NumberFormat format;
+ try {
+ format = NumberFormat.simpleCurrency(locale: locale);
+ } on ArgumentError {
+ format = NumberFormat.simpleCurrency(locale: "en_US");
+ }
// See https://stackoverflow.com/a/67055685
return ConditionalParent(
@@ -841,9 +881,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
.extension<StackColors>()!
.currencyListItemBG
.withOpacity(_hovering1 ? 0.3 : 0)
- : Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
@@ -881,9 +921,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Text(
"I want to pay with",
style: STextStyles.itemSubtitle(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
],
@@ -907,9 +947,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
.extension<StackColors>()!
.currencyListItemBG
.withOpacity(_hovering2 ? 0.3 : 0)
- : Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Padding(
padding: const EdgeInsets.only(
left: 12.0,
@@ -925,9 +965,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
horizontal: 6,
),
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.currencyListItemBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .currencyListItemBG,
borderRadius: BorderRadius.circular(4),
),
child: Text(
@@ -936,9 +976,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
),
textAlign: TextAlign.center,
style: STextStyles.smallMed12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -976,9 +1016,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Text(
buyWithFiat ? "Enter amount" : "Enter crypto amount",
style: STextStyles.itemSubtitle(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
CustomTextButton(
@@ -1013,7 +1053,13 @@ class _BuyFormState extends ConsumerState<BuyForm> {
decimal: true,
),
textAlign: TextAlign.left,
- // inputFormatters: [NumericalRangeFormatter()],
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: _buyAmountController,
+ decimals: buyWithFiat ? 2 : 30,
+ locale: locale,
+ ),
+ ],
onChanged: (_) {
validateAmount();
},
@@ -1029,9 +1075,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -1077,9 +1123,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
? selectedFiat?.ticker ?? "ERR"
: selectedCrypto?.ticker ?? "ERR",
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
],
@@ -1123,12 +1169,17 @@ class _BuyFormState extends ConsumerState<BuyForm> {
final ClipboardData? data = await clipboard
.getData(Clipboard.kTextPlain);
- final amountString = Decimal.tryParse(
+ final amount = Amount.tryParseEditableAmount(
data?.text ?? "",
+ locale: locale,
+ fractionDigits: buyWithFiat ? 2 : 30,
);
- if (amountString != null) {
- _buyAmountController.text = amountString
- .toString();
+ if (amount != null) {
+ _buyAmountController.text =
+ Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: locale,
+ );
validateAmount();
}
@@ -1157,9 +1208,9 @@ class _BuyFormState extends ConsumerState<BuyForm> {
Text(
"Enter receiving address",
style: STextStyles.itemSubtitle(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
if (AppConfig.isStackCoin(selectedCrypto?.ticker))
### lib/pages/cakepay/cakepay_card_detail_view.dart
@@ -1,10 +1,15 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../providers/global/locale_provider.dart';
import '../../services/cakepay/cakepay_service.dart';
import '../../services/cakepay/src/models/card.dart';
import '../../themes/stack_colors.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
+import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/background.dart';
@@ -21,18 +26,19 @@ import '../../widgets/stack_dialog.dart';
import '../../widgets/textfields/adaptive_text_field.dart';
import 'cakepay_order_view.dart';
-class CakePayCardDetailView extends StatefulWidget {
+class CakePayCardDetailView extends ConsumerStatefulWidget {
const CakePayCardDetailView({super.key, required this.card});
static const String routeName = "/cakePayCardDetail";
final CakePayCard card;
@override
- State<CakePayCardDetailView> createState() => _CakePayCardDetailViewState();
+ ConsumerState<CakePayCardDetailView> createState() =>
+ _CakePayCardDetailViewState();
}
-class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
+class _CakePayCardDetailViewState extends ConsumerState<CakePayCardDetailView> {
late CakePayCard _card;
bool _purchasing = false;
Decimal? _selectedDenomination;
@@ -52,23 +58,24 @@ class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
}
}
- String get _priceString {
+ Decimal? get _price {
if (_card.isFixedDenomination && _selectedDenomination != null) {
- return _selectedDenomination!.toStringAsFixed(2);
+ return _selectedDenomination;
}
- return _customAmountController.text.trim();
+ return Amount.tryParseFiatString(
+ _customAmountController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ )?.decimal;
}
bool _checkCanPurchase() {
if (!_termsAccepted || _purchasing) return false;
if (_emailController.text.trim().isEmpty) return false;
- final price = _priceString;
- if (price.isEmpty) return false;
- final parsed = Decimal.tryParse(price);
- if (parsed == null || parsed <= Decimal.zero) return false;
+ final price = _price;
+ if (price == null || price <= Decimal.zero) return false;
if (_card.isRangeDenomination) {
- if (_card.minValue != null && parsed < _card.minValue!) return false;
- if (_card.maxValue != null && parsed > _card.maxValue!) return false;
+ if (_card.minValue != null && price < _card.minValue!) return false;
+ if (_card.maxValue != null && price > _card.maxValue!) return false;
}
return true;
}
@@ -80,11 +87,13 @@ class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
Future<void> _purchase() async {
if (!_checkCanPurchase()) return;
+ final price = _price;
+ if (price == null) return;
setState(() => _purchasing = true);
final resp = await CakePayService.instance.client.createOrder(
cardId: _card.id,
- price: _priceString,
+ price: price.toStringAsFixed(2),
quantity: _quantity > 1 ? _quantity : null,
userEmail: _emailController.text.trim(),
confirmsNoVpn: true,
@@ -150,6 +159,14 @@ class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
final card = _card;
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_customAmountController],
+ onRelocalized: _updateCanPurchase,
+ );
return ConditionalParent(
condition: isDesktop,
@@ -186,9 +203,9 @@ class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
condition: !isDesktop,
builder: (child) => Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () => Navigator.of(context).pop(),
@@ -250,6 +267,7 @@ class _CakePayCardDetailViewState extends State<CakePayCardDetailView> {
_DenominationSelector(
card: card,
isDesktop: isDesktop,
+ locale: locale,
selectedDenomination: _selectedDenomination,
customAmountController: _customAmountController,
onDenominationSelected: (Decimal d) {
@@ -414,6 +432,7 @@ class _DenominationSelector extends StatelessWidget {
const _DenominationSelector({
required this.card,
required this.isDesktop,
+ required this.locale,
required this.selectedDenomination,
required this.customAmountController,
required this.onDenominationSelected,
@@ -422,13 +441,18 @@ class _DenominationSelector extends StatelessWidget {
final CakePayCard card;
final bool isDesktop;
+ final String locale;
final Decimal? selectedDenomination;
final TextEditingController customAmountController;
final ValueChanged<Decimal> onDenominationSelected;
final VoidCallback onCustomAmountChanged;
@override
Widget build(BuildContext context) {
+ String formatPrice(Decimal? price) => price == null
+ ? "?"
+ : Amount.formatFixedDecimal(price, fractionDigits: 2, locale: locale);
+
if (card.isFixedDenomination) {
return Wrap(
spacing: 8,
@@ -437,16 +461,16 @@ class _DenominationSelector extends StatelessWidget {
final bool selected = d == selectedDenomination;
return ChoiceChip(
label: Text(
- "${d.toStringAsFixed(2)} ${card.currencyCode ?? ''}",
+ "${formatPrice(d)} ${card.currencyCode ?? ''}",
style:
(isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
: STextStyles.itemSubtitle12(context))
.copyWith(
color: selected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.textDark
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .textDark
: null,
),
),
@@ -465,8 +489,8 @@ class _DenominationSelector extends StatelessWidget {
mainAxisSize: .min,
children: [
Text(
- "Enter amount (${card.minValue?.toStringAsFixed(2) ?? '?'} - "
- "${card.maxValue?.toStringAsFixed(2) ?? '?'} "
+ "Enter amount (${formatPrice(card.minValue)} - "
+ "${formatPrice(card.maxValue)} "
"${card.currencyCode ?? ''})",
style: isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
@@ -477,6 +501,13 @@ class _DenominationSelector extends StatelessWidget {
labelText: "Amount",
controller: customAmountController,
keyboardType: const .numberWithOptions(decimal: true),
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: customAmountController,
+ decimals: 2,
+ locale: locale,
+ ),
+ ],
onChangedComprehensive: (_) => onCustomAmountChanged(),
),
],
@@ -574,9 +605,8 @@ class _TermsCheckbox extends StatelessWidget {
const TextSpan(text: "I agree to the "),
TextSpan(
text: "terms and conditions",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: isDesktop ? null : 14),
+ style: STextStyles.richLink(context)
+ .copyWith(fontSize: isDesktop ? null : 14),
recognizer: TapGestureRecognizer()..onTap = onOpenTerms,
),
const TextSpan(
### lib/pages/cakepay/cakepay_order_view.dart
@@ -461,9 +461,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
)
: STextStyles.itemSubtitle12(context))
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
],
@@ -548,13 +548,13 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
: STextStyles.itemSubtitle12(context))
.copyWith(
color: isExpired
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorRed
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorRed
: remaining.inMinutes < 5
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorOrange
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorOrange
: null,
fontWeight: FontWeight.w600,
),
@@ -583,9 +583,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
Assets.svg.checkCircle,
width: 20,
height: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorGreen,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorGreen,
),
const SizedBox(width: 8),
Expanded(
@@ -600,9 +600,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
)
: STextStyles.itemSubtitle12(context))
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorGreen,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorGreen,
),
),
),
@@ -648,9 +648,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
Assets.svg.circleX,
width: 20,
height: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
const SizedBox(width: 8),
Expanded(
@@ -661,9 +661,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
? STextStyles.desktopTextExtraExtraSmall(context)
: STextStyles.itemSubtitle12(context))
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
),
@@ -725,9 +725,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
border: Border(
bottom: BorderSide(
color: isSelected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorBlue
: Colors.transparent,
width: 2,
),
@@ -744,9 +744,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
: STextStyles.itemSubtitle12(context))
.copyWith(
color: isSelected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorBlue
: null,
fontWeight: isSelected ? FontWeight.w600 : null,
),
@@ -816,7 +816,7 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
: STextStyles.itemSubtitle12(context),
),
const Spacer(),
- IconCopyButton(data: order.orderId),
+ IconCopyButton(data: selected.address),
const SizedBox(width: 4),
Text("Copy", style: STextStyles.link2(context)),
],
@@ -918,9 +918,9 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
condition: !isDesktop,
builder: (child) => Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () => Navigator.of(context).pop(),
### lib/pages/coin_control/coin_control_view.dart
@@ -18,6 +18,7 @@ import 'package:tuple/tuple.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/isar_models.dart';
+import '../../providers/global/locale_provider.dart';
import '../../providers/global/wallets_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
@@ -123,15 +124,17 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
- final minConfirms =
- ref
- .watch(pWallets)
- .getWallet(widget.walletId)
- .cryptoCurrency
- .minConfirms;
+ final minConfirms = ref
+ .watch(pWallets)
+ .getWallet(widget.walletId)
+ .cryptoCurrency
+ .minConfirms;
final coin = ref.watch(pWalletCoin(widget.walletId));
final currentHeight = ref.watch(pWalletChainHeight(widget.walletId));
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
if (_sort == CCSortDescriptor.address && !_isSearching) {
_list = null;
@@ -140,20 +143,21 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
filter: CCFilter.all,
sort: _sort,
searchTerm: "",
+ locale: locale,
cryptoCurrency: coin,
);
} else {
_map = null;
_list = MainDB.instance.queryUTXOsSync(
walletId: widget.walletId,
- filter:
- _isSearching
- ? CCFilter.all
- : _showBlocked
- ? CCFilter.frozen
- : CCFilter.available,
+ filter: _isSearching
+ ? CCFilter.all
+ : _showBlocked
+ ? CCFilter.frozen
+ : CCFilter.available,
sort: _sort,
searchTerm: _isSearching ? searchController.text : "",
+ locale: locale,
cryptoCurrency: coin,
);
}
@@ -168,115 +172,107 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
},
child: Background(
child: Scaffold(
- backgroundColor:
- Theme.of(context).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
automaticallyImplyLeading: false,
- leading:
- _isSearching
- ? null
- : widget.type == CoinControlViewType.use &&
- _selectedAvailable.isNotEmpty
- ? AppBarIconButton(
- icon: XIcon(
- width: 24,
- height: 24,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
- ),
- onPressed: () {
- setState(() {
- _selectedAvailable.clear();
- });
- },
- )
- : AppBarBackButton(
- onPressed: () {
- unawaited(_refreshBalance());
- Navigator.of(context).pop(
- widget.type == CoinControlViewType.use
- ? _selectedAvailable
- : null,
- );
- },
- ),
- title:
- _isSearching
- ? AppBarSearchField(
- controller: searchController,
- focusNode: searchFocus,
- )
- : Text(
- "Coin control",
- style: STextStyles.navBarTitle(context),
+ leading: _isSearching
+ ? null
+ : widget.type == CoinControlViewType.use &&
+ _selectedAvailable.isNotEmpty
+ ? AppBarIconButton(
+ icon: XIcon(
+ width: 24,
+ height: 24,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
),
+ onPressed: () {
+ setState(() {
+ _selectedAvailable.clear();
+ });
+ },
+ )
+ : AppBarBackButton(
+ onPressed: () {
+ unawaited(_refreshBalance());
+ Navigator.of(context).pop(
+ widget.type == CoinControlViewType.use
+ ? _selectedAvailable
+ : null,
+ );
+ },
+ ),
+ title: _isSearching
+ ? AppBarSearchField(
+ controller: searchController,
+ focusNode: searchFocus,
+ )
+ : Text("Coin control", style: STextStyles.navBarTitle(context)),
titleSpacing: 0,
- actions:
- _isSearching
- ? [
- AspectRatio(
- aspectRatio: 1,
- child: AppBarIconButton(
- size: 36,
- icon: SvgPicture.asset(
- Assets.svg.x,
- width: 20,
- height: 20,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
- ),
- onPressed: () {
- // show search
- setState(() {
- _isSearching = false;
- });
- },
+ actions: _isSearching
+ ? [
+ AspectRatio(
+ aspectRatio: 1,
+ child: AppBarIconButton(
+ size: 36,
+ icon: SvgPicture.asset(
+ Assets.svg.x,
+ width: 20,
+ height: 20,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
),
+ onPressed: () {
+ // show search
+ setState(() {
+ _isSearching = false;
+ });
+ },
),
- ]
- : [
- AspectRatio(
- aspectRatio: 1,
- child: AppBarIconButton(
- size: 36,
- icon: SvgPicture.asset(
- Assets.svg.search,
- width: 20,
- height: 20,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
- ),
- onPressed: () {
- // show search
- setState(() {
- _isSearching = true;
- });
- },
+ ),
+ ]
+ : [
+ AspectRatio(
+ aspectRatio: 1,
+ child: AppBarIconButton(
+ size: 36,
+ icon: SvgPicture.asset(
+ Assets.svg.search,
+ width: 20,
+ height: 20,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
),
+ onPressed: () {
+ // show search
+ setState(() {
+ _isSearching = true;
+ });
+ },
),
- AspectRatio(
- aspectRatio: 1,
- child: JDropdownIconButton(
- mobileAppBar: true,
- groupValue: _sort,
- items: CCSortDescriptor.values.toSet(),
- onSelectionChanged: (CCSortDescriptor? newValue) {
- if (newValue != null && newValue != _sort) {
- setState(() {
- _sort = newValue;
- });
- }
- },
- displayPrefix: "Sort by",
- ),
+ ),
+ AspectRatio(
+ aspectRatio: 1,
+ child: JDropdownIconButton(
+ mobileAppBar: true,
+ groupValue: _sort,
+ items: CCSortDescriptor.values.toSet(),
+ onSelectionChanged: (CCSortDescriptor? newValue) {
+ if (newValue != null && newValue != _sort) {
+ setState(() {
+ _sort = newValue;
+ });
+ }
+ },
+ displayPrefix: "Sort by",
),
- ],
+ ),
+ ],
),
body: SafeArea(
child: Column(
@@ -294,10 +290,9 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
"outputs at your discretion. Tap the output circle to "
"select.",
style: STextStyles.w500_14(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
),
@@ -307,15 +302,13 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
height: 48,
child: Toggle(
key: UniqueKey(),
- onColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ onColor: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
onText: "Available outputs",
- offColor:
- Theme.of(context)
- .extension<StackColors>()!
- .textFieldDefaultBG,
+ offColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
offText: "Frozen outputs",
isOn: _showBlocked,
onValueChanged: (value) {
@@ -336,14 +329,13 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
Expanded(
child: ListView.separated(
itemCount: _list!.length,
- separatorBuilder:
- (context, _) => const SizedBox(height: 10),
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
itemBuilder: (context, index) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(_list![index])
- .findFirstSync()!;
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(_list![index])
+ .findFirstSync()!;
final isSelected =
_selectedBlocked.contains(utxo) ||
@@ -385,15 +377,14 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
setState(() {});
},
onPressed: () async {
- final result = await Navigator.of(
- context,
- ).pushNamed(
- UtxoDetailsView.routeName,
- arguments: Tuple2(
- utxo.id,
- widget.walletId,
- ),
- );
+ final result = await Navigator.of(context)
+ .pushNamed(
+ UtxoDetailsView.routeName,
+ arguments: Tuple2(
+ utxo.id,
+ widget.walletId,
+ ),
+ );
if (mounted && result == "refresh") {
setState(() {});
}
@@ -405,244 +396,235 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
if (!_isSearching)
_list != null
? Expanded(
- child: ListView.separated(
- itemCount: _list!.length,
- separatorBuilder:
- (context, _) =>
- const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(_list![index])
- .findFirstSync()!;
+ child: ListView.separated(
+ itemCount: _list!.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(_list![index])
+ .findFirstSync()!;
- final isSelected =
- _showBlocked
- ? _selectedBlocked.contains(utxo)
- : _selectedAvailable.contains(utxo);
+ final isSelected = _showBlocked
+ ? _selectedBlocked.contains(utxo)
+ : _selectedAvailable.contains(utxo);
- return UtxoCard(
- key: Key(
- "${utxo.walletId}_${utxo.id}_$isSelected",
- ),
- walletId: widget.walletId,
- utxo: utxo,
- canSelect:
- widget.type ==
- CoinControlViewType.manage ||
- (widget.type ==
- CoinControlViewType.use &&
- !_showBlocked &&
- _isConfirmed(
- utxo,
- currentHeight,
- ref.watch(
- pWallets.select(
- (s) => s.getWallet(
- widget.walletId,
+ return UtxoCard(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_$isSelected",
+ ),
+ walletId: widget.walletId,
+ utxo: utxo,
+ canSelect:
+ widget.type ==
+ CoinControlViewType.manage ||
+ (widget.type ==
+ CoinControlViewType.use &&
+ !_showBlocked &&
+ _isConfirmed(
+ utxo,
+ currentHeight,
+ ref.watch(
+ pWallets.select(
+ (s) => s.getWallet(
+ widget.walletId,
+ ),
),
),
- ),
- )),
- initialSelectedState: isSelected,
- onSelectedChanged: (value) {
- if (value) {
- _showBlocked
- ? _selectedBlocked.add(utxo)
- : _selectedAvailable.add(utxo);
- } else {
- _showBlocked
- ? _selectedBlocked.remove(utxo)
- : _selectedAvailable.remove(utxo);
- }
- setState(() {});
- },
- onPressed: () async {
- final result = await Navigator.of(
- context,
- ).pushNamed(
- UtxoDetailsView.routeName,
- arguments: Tuple2(
- utxo.id,
- widget.walletId,
- ),
- );
- if (mounted && result == "refresh") {
+ )),
+ initialSelectedState: isSelected,
+ onSelectedChanged: (value) {
+ if (value) {
+ _showBlocked
+ ? _selectedBlocked.add(utxo)
+ : _selectedAvailable.add(utxo);
+ } else {
+ _showBlocked
+ ? _selectedBlocked.remove(utxo)
+ : _selectedAvailable.remove(
+ utxo,
+ );
+ }
setState(() {});
- }
- },
- );
- },
- ),
- )
+ },
+ onPressed: () async {
+ final result =
+ await Navigator.of(context)
+ .pushNamed(
+ UtxoDetailsView.routeName,
+ arguments: Tuple2(
+ utxo.id,
+ widget.walletId,
+ ),
+ );
+ if (mounted && result == "refresh") {
+ setState(() {});
+ }
+ },
+ );
+ },
+ ),
+ )
: Expanded(
- child: ListView.separated(
- itemCount: _map!.entries.length,
- separatorBuilder:
- (context, _) =>
- const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final entry = _map!.entries.elementAt(
- index,
- );
- final _controller = RotateIconController();
+ child: ListView.separated(
+ itemCount: _map!.entries.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final entry = _map!.entries.elementAt(
+ index,
+ );
+ final _controller =
+ RotateIconController();
- return Expandable2(
- border:
- Theme.of(context)
- .extension<StackColors>()!
- .backgroundAppBar,
- background:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- onExpandWillChange: (state) {
- if (state ==
- Expandable2State.expanded) {
- _controller.forward?.call();
- } else {
- _controller.reverse?.call();
- }
- },
- header: RoundedContainer(
- padding: const EdgeInsets.all(14),
- color: Colors.transparent,
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.start,
- children: [
- Text(
- entry.key,
- style: STextStyles.w600_14(
- context,
+ return Expandable2(
+ border: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
+ background: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
+ animationDurationMultiplier:
+ 0.2 * entry.value.length,
+ onExpandWillChange: (state) {
+ if (state ==
+ Expandable2State.expanded) {
+ _controller.forward?.call();
+ } else {
+ _controller.reverse?.call();
+ }
+ },
+ header: RoundedContainer(
+ padding: const EdgeInsets.all(14),
+ color: Colors.transparent,
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children: [
+ Text(
+ entry.key,
+ style:
+ STextStyles.w600_14(
+ context,
+ ),
),
- ),
- const SizedBox(height: 2),
- Text(
- "${entry.value.length} "
- "output${entry.value.length > 1 ? "s" : ""}",
- style: STextStyles.w500_12(
- context,
- ).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textSubtitle1,
+ const SizedBox(height: 2),
+ Text(
+ "${entry.value.length} "
+ "output${entry.value.length > 1 ? "s" : ""}",
+ style:
+ STextStyles.w500_12(
+ context,
+ ).copyWith(
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textSubtitle1,
+ ),
),
- ),
- ],
+ ],
+ ),
),
- ),
- RotateIcon(
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- icon: SvgPicture.asset(
- Assets.svg.chevronDown,
- width: 14,
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textSubtitle1,
+ RotateIcon(
+ animationDurationMultiplier:
+ 0.2 * entry.value.length,
+ icon: SvgPicture.asset(
+ Assets.svg.chevronDown,
+ width: 14,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
+ ),
+ curve: Curves.easeInOut,
+ controller: _controller,
),
- curve: Curves.easeInOut,
- controller: _controller,
- ),
- ],
+ ],
+ ),
),
- ),
- children:
- entry.value.map((id) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(id)
- .findFirstSync()!;
+ children: entry.value.map((id) {
+ final utxo = MainDB
+ .instance
+ .isar
+ .utxos
+ .where()
+ .idEqualTo(id)
+ .findFirstSync()!;
- final isSelected =
- _selectedBlocked.contains(
- utxo,
- ) ||
- _selectedAvailable.contains(
- utxo,
- );
+ final isSelected =
+ _selectedBlocked.contains(utxo) ||
+ _selectedAvailable.contains(utxo);
- return UtxoCard(
- key: Key(
- "${utxo.walletId}_${utxo.id}_$isSelected",
- ),
- walletId: widget.walletId,
- utxo: utxo,
- canSelect:
- widget.type ==
- CoinControlViewType
- .manage ||
- (widget.type ==
- CoinControlViewType
- .use &&
- !utxo.isBlocked &&
- _isConfirmed(
- utxo,
- currentHeight,
- ref.watch(
- pWallets.select(
- (s) => s.getWallet(
- widget.walletId,
- ),
+ return UtxoCard(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_$isSelected",
+ ),
+ walletId: widget.walletId,
+ utxo: utxo,
+ canSelect:
+ widget.type ==
+ CoinControlViewType
+ .manage ||
+ (widget.type ==
+ CoinControlViewType
+ .use &&
+ !utxo.isBlocked &&
+ _isConfirmed(
+ utxo,
+ currentHeight,
+ ref.watch(
+ pWallets.select(
+ (s) => s.getWallet(
+ widget.walletId,
),
),
- )),
- initialSelectedState: isSelected,
- onSelectedChanged: (value) {
- if (value) {
- utxo.isBlocked
- ? _selectedBlocked.add(
- utxo,
- )
- : _selectedAvailable.add(
+ ),
+ )),
+ initialSelectedState: isSelected,
+ onSelectedChanged: (value) {
+ if (value) {
+ utxo.isBlocked
+ ? _selectedBlocked.add(utxo)
+ : _selectedAvailable.add(
utxo,
);
- } else {
- utxo.isBlocked
- ? _selectedBlocked.remove(
+ } else {
+ utxo.isBlocked
+ ? _selectedBlocked.remove(
utxo,
)
- : _selectedAvailable
- .remove(utxo);
- }
+ : _selectedAvailable.remove(
+ utxo,
+ );
+ }
+ setState(() {});
+ },
+ onPressed: () async {
+ final result =
+ await Navigator.of(
+ context,
+ ).pushNamed(
+ UtxoDetailsView.routeName,
+ arguments: Tuple2(
+ utxo.id,
+ widget.walletId,
+ ),
+ );
+ if (mounted &&
+ result == "refresh") {
setState(() {});
- },
- onPressed: () async {
- final result =
- await Navigator.of(
- context,
- ).pushNamed(
- UtxoDetailsView.routeName,
- arguments: Tuple2(
- utxo.id,
- widget.walletId,
- ),
- );
- if (mounted &&
- result == "refresh") {
- setState(() {});
- }
- },
- );
- }).toList(),
- );
- },
+ }
+ },
+ );
+ }).toList(),
+ );
+ },
+ ),
),
- ),
],
),
),
@@ -652,14 +634,13 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
widget.type == CoinControlViewType.manage)
Container(
decoration: BoxDecoration(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
boxShadow: [
- Theme.of(
- context,
- ).extension<StackColors>()!.standardBoxShadow,
+ Theme.of(context)
+ .extension<StackColors>()!
+ .standardBoxShadow,
],
),
child: Padding(
@@ -690,14 +671,13 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
if (!_showBlocked && widget.type == CoinControlViewType.use)
Container(
decoration: BoxDecoration(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
boxShadow: [
- Theme.of(
- context,
- ).extension<StackColors>()!.standardBoxShadow,
+ Theme.of(context)
+ .extension<StackColors>()!
+ .standardBoxShadow,
],
),
child: Padding(
@@ -722,13 +702,13 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
builder: (context) {
final int selectedSumInt =
_selectedAvailable.isEmpty
- ? 0
- : _selectedAvailable
- .map((e) => e.value)
- .reduce(
- (value, element) =>
- value += element,
- );
+ ? 0
+ : _selectedAvailable
+ .map((e) => e.value)
+ .reduce(
+ (value, element) =>
+ value += element,
+ );
final selectedSum = selectedSumInt
.toAmountAsRaw(
fractionDigits:
@@ -738,33 +718,26 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
ref
.watch(pAmountFormatter(coin))
.format(selectedSum),
- style:
- widget.requestedTotal == null
- ? STextStyles.w600_14(
- context,
- )
- : STextStyles.w600_14(
- context,
- ).copyWith(
- color:
- selectedSum >=
- widget
- .requestedTotal!
- ? Theme.of(
- context,
- )
- .extension<
- StackColors
- >()!
- .accentColorGreen
- : Theme.of(
- context,
- )
- .extension<
- StackColors
- >()!
- .accentColorRed,
- ),
+ style: widget.requestedTotal == null
+ ? STextStyles.w600_14(context)
+ : STextStyles.w600_14(
+ context,
+ ).copyWith(
+ color:
+ selectedSum >=
+ widget
+ .requestedTotal!
+ ? Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .accentColorGreen
+ : Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .accentColorRed,
+ ),
);
},
),
@@ -775,10 +748,9 @@ class _CoinControlViewState extends ConsumerState<CoinControlView> {
Container(
width: double.infinity,
height: 1.5,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .backgroundAppBar,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
),
if (widget.requestedTotal != null)
Padding(
### lib/pages/exchange_view/exchange_form.dart
@@ -37,7 +37,8 @@ import '../../services/exchange/nanswap/nanswap_exchange.dart';
import '../../services/exchange/trocador/trocador_exchange.dart';
import '../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
import '../../themes/stack_colors.dart';
-import '../../utilities/amount/amount_unit.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/assets.dart';
import '../../utilities/constants.dart';
import '../../utilities/enums/exchange_rate_type_enum.dart';
@@ -105,6 +106,10 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
// todo: check and adjust this value?
static const _valueCheckInterval = Duration(milliseconds: 1500);
+ String? _pendingSendAmountText;
+ String? _pendingReceiveAmountText;
+ late String _amountInputLocale;
+
Future<T> showUpdatingExchangeRate<T>({
required Future<T> whileFuture,
}) async {
@@ -115,9 +120,10 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
builder: (_) => WillPopScope(
onWillPop: () async => false,
child: Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.overlay.withOpacity(0.6),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .overlay
+ .withOpacity(0.6),
child: const CustomLoadingOverlay(
message: "Updating exchange rate",
eventBus: null,
@@ -140,9 +146,13 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
void sendFieldOnChanged(String value) {
if (_sendFocusNode.hasFocus) {
_sendFieldOnChangedTimer?.cancel();
+ _pendingSendAmountText = value;
_sendFieldOnChangedTimer = Timer(_valueCheckInterval, () async {
- final newFromAmount = _localizedStringToNum(value);
+ final pendingText = _pendingSendAmountText ?? value;
+ _pendingSendAmountText = null;
+ _sendFieldOnChangedTimer = null;
+ final newFromAmount = _localizedStringToNum(pendingText);
ref.read(efSendAmountProvider.notifier).state = newFromAmount;
if (!_swapLock && !ref.read(efReversedProvider)) {
@@ -155,9 +165,13 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
Timer? _receiveFieldOnChangedTimer;
void receiveFieldOnChanged(String value) async {
_receiveFieldOnChangedTimer?.cancel();
+ _pendingReceiveAmountText = value;
_receiveFieldOnChangedTimer = Timer(_valueCheckInterval, () async {
- final newToAmount = _localizedStringToNum(value);
+ final pendingText = _pendingReceiveAmountText ?? value;
+ _pendingReceiveAmountText = null;
+ _receiveFieldOnChangedTimer = null;
+ final newToAmount = _localizedStringToNum(pendingText);
ref.read(efReceiveAmountProvider.notifier).state = newToAmount;
if (!_swapLock && ref.read(efReversedProvider)) {
@@ -166,21 +180,72 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
});
}
+ bool _flushPendingAmountChange() {
+ final flushSend = _sendFieldOnChangedTimer?.isActive ?? false;
+ final flushReceive = _receiveFieldOnChangedTimer?.isActive ?? false;
+ String? sendText;
+ String? receiveText;
+
+ // Capture both edits before publishing either provider state. A provider
+ // refresh may rewrite either controller once the first state is visible.
+ if (flushSend) {
+ _sendFieldOnChangedTimer!.cancel();
+ _sendFieldOnChangedTimer = null;
+ sendText = _pendingSendAmountText ?? _sendController.text;
+ _pendingSendAmountText = null;
+ }
+ if (flushReceive) {
+ _receiveFieldOnChangedTimer!.cancel();
+ _receiveFieldOnChangedTimer = null;
+ receiveText = _pendingReceiveAmountText ?? _receiveController.text;
+ _pendingReceiveAmountText = null;
+ }
+
+ if (flushSend) {
+ ref.read(efSendAmountProvider.notifier).state = _localizedStringToNum(
+ sendText,
+ );
+ }
+ if (flushReceive) {
+ ref.read(efReceiveAmountProvider.notifier).state = _localizedStringToNum(
+ receiveText,
+ );
+ }
+ return flushSend || flushReceive;
+ }
+
Decimal? _localizedStringToNum(String? value) {
if (value == null) {
return null;
}
- return AmountUnit.normal
- .tryParse(
- value,
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- coin: Bitcoin(
- CryptoCurrencyNetwork.main,
- ), // dummy value (not used due to override)
- overrideWithDecimalPlacesFromString: true,
- )
- ?.decimal;
+ return Amount.tryParseEditableDecimal(
+ value,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ }
+
+ void _relocalizePendingAmountText() {
+ final nextLocale = ref.read(localeServiceChangeNotifierProvider).locale;
+ if (nextLocale == _amountInputLocale) {
+ return;
+ }
+
+ if (_pendingSendAmountText != null) {
+ _pendingSendAmountText = Amount.relocalizeEditableDecimal(
+ _pendingSendAmountText!,
+ sourceLocale: _amountInputLocale,
+ targetLocale: nextLocale,
+ );
+ }
+ if (_pendingReceiveAmountText != null) {
+ _pendingReceiveAmountText = Amount.relocalizeEditableDecimal(
+ _pendingReceiveAmountText!,
+ sourceLocale: _amountInputLocale,
+ targetLocale: nextLocale,
+ );
+ }
+ _amountInputLocale = nextLocale;
}
void selectSendCurrency() async {
@@ -234,6 +299,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
}
Future<void> _swap() async {
+ _flushPendingAmountChange();
_swapLock = true;
_sendFocusNode.unfocus();
_receiveFocusNode.unfocus();
@@ -303,9 +369,9 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
Expanded(
child: RoundedWhiteContainer(
padding: const EdgeInsets.all(16),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: ExchangeCurrencySelectionView(
pairedCurrency: paired,
isFixedRate: isFixedRate,
@@ -392,6 +458,11 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
}
void onExchangePressed() async {
+ if (_flushPendingAmountChange()) {
+ await showUpdatingExchangeRate(whileFuture: update());
+ if (!mounted) return;
+ }
+
final exchangeName = ref.read(efExchangeProvider).name;
final fromCurrency = ref
@@ -421,8 +492,24 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
}
final rateType = ref.read(efRateTypeProvider);
- final estimate = ref.read(efEstimateProvider)!;
- final sendAmount = ref.read(efSendAmountProvider)!;
+ final estimate = ref.read(efEstimateProvider);
+ final sendAmount = ref.read(efSendAmountProvider);
+
+ if (estimate == null || sendAmount == null) {
+ if (mounted) {
+ await showDialog<void>(
+ context: context,
+ builder: (context) => StackOkDialog(
+ title: "Exchange rate not ready",
+ message:
+ "Please wait for the exchange rate to update and try again",
+ maxWidth: Util.isDesktop ? 300 : null,
+ ),
+ );
+ }
+
+ return;
+ }
if (rateType == ExchangeRateType.fixed &&
toCurrency.ticker.toUpperCase() == "WOW") {
@@ -431,8 +518,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
context: context,
builder: (context) => const StackOkDialog(
title: "WOW error",
- message:
- "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues",
+ message: "Wownero is temporarily disabled as a receiving currency for fixed rate trades due to network issues",
),
);
}
@@ -594,9 +680,8 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
);
} else {
unawaited(
- Navigator.of(
- context,
- ).pushNamed(Step2View.routeName, arguments: model),
+ Navigator.of(context)
+ .pushNamed(Step2View.routeName, arguments: model),
);
}
} else {
@@ -617,9 +702,8 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
);
} else {
unawaited(
- Navigator.of(
- context,
- ).pushNamed(Step1View.routeName, arguments: model),
+ Navigator.of(context)
+ .pushNamed(Step1View.routeName, arguments: model),
);
}
}
@@ -645,9 +729,30 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
Future<void> update() async {
final uuid = const Uuid().v1();
_latestUuid = uuid;
- _addUpdate(uuid);
- for (final exchange in usableExchanges) {
- ref.read(efEstimatesListProvider(exchange.name).notifier).state = null;
+
+ final exchanges = usableExchanges;
+ final estimatesNotifiers = {
+ for (final exchange in exchanges)
+ exchange.name: ref.read(
+ efEstimatesListProvider(exchange.name).notifier,
+ ),
+ };
+ final refreshingNotifier = ref.read(efRefreshingProvider.notifier);
+
+ _uuids.add(uuid);
+ refreshingNotifier.state = true;
+
+ void removeUpdate() {
+ _uuids.remove(uuid);
+ if (_uuids.isEmpty) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ refreshingNotifier.state = false;
+ });
+ }
+ }
+
+ for (final exchange in exchanges) {
+ estimatesNotifiers[exchange.name]!.state = null;
}
final reversed = ref.read(efReversedProvider);
@@ -660,14 +765,14 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
amount <= Decimal.zero ||
pair.send == null ||
pair.receive == null) {
- _removeUpdate(uuid);
+ removeUpdate();
return;
}
final rateType = ref.read(efRateTypeProvider);
final Map<String, Tuple2<ExchangeResponse<List<Estimate>>, Range?>>
results = {};
- for (final exchange in usableExchanges) {
+ for (final exchange in exchanges) {
final sendCurrency = pair.send?.forExchange(exchange.name);
final receiveCurrency = pair.receive?.forExchange(exchange.name);
@@ -704,33 +809,18 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
}
}
- for (final exchange in usableExchanges) {
+ for (final exchange in exchanges) {
if (uuid == _latestUuid) {
- ref.read(efEstimatesListProvider(exchange.name).notifier).state =
- results[exchange.name];
+ estimatesNotifiers[exchange.name]!.state = results[exchange.name];
}
}
- _removeUpdate(uuid);
+ removeUpdate();
}
String? _latestUuid;
final Set<String> _uuids = {};
- void _addUpdate(String uuid) {
- _uuids.add(uuid);
- ref.read(efRefreshingProvider.notifier).state = true;
- }
-
- void _removeUpdate(String uuid) {
- _uuids.remove(uuid);
- if (_uuids.isEmpty) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- ref.read(efRefreshingProvider.notifier).state = false;
- });
- }
- }
-
void updateSend(Estimate? estimate) {
ref.read(efSendAmountProvider.notifier).state = estimate?.estimatedAmount;
}
@@ -744,6 +834,7 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
void initState() {
_sendController = TextEditingController();
_receiveController = TextEditingController();
+ _amountInputLocale = ref.read(localeServiceChangeNotifierProvider).locale;
walletId = widget.walletId;
coin = widget.coin;
@@ -809,6 +900,8 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
@override
void dispose() {
+ _sendFieldOnChangedTimer?.cancel();
+ _receiveFieldOnChangedTimer?.cancel();
_receiveController.dispose();
_sendController.dispose();
_receiveFocusNode.dispose();
@@ -824,6 +917,20 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
final isEstimated = rateType == ExchangeRateType.estimated;
+ // A pending debounce captured text in the old locale. Relocalize both the
+ // controllers and the stored user edits before parsing under the new
+ // locale, then refresh the quote for the newly committed amount.
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_sendController, _receiveController],
+ onRelocalized: () {
+ _relocalizePendingAmountText();
+ if (_flushPendingAmountChange()) {
+ unawaited(update());
+ }
+ },
+ );
+
ref.listen(efReceiveAmountStringProvider, (previous, String next) {
if (!_receiveFocusNode.hasFocus) {
_receiveController.text = isEstimated && next.isEmpty ? "-" : next;
@@ -866,6 +973,10 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
});
ref.listen(efCurrencyPairProvider, (previous, next) {
+ // Commit pending user text before its debounce can apply after the pair
+ // has changed. The controller may already have been rewritten from the
+ // previous provider value while focus moved between the fields.
+ _flushPendingAmountChange();
if (!_swapLock) {
update();
}
@@ -893,13 +1004,13 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
textStyle: STextStyles.smallMed14(context).copyWith(
color: Theme.of(context).extension<StackColors>()!.textDark,
),
- buttonColor: Theme.of(
- context,
- ).extension<StackColors>()!.buttonBackSecondary,
+ buttonColor: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonBackSecondary,
borderRadius: Constants.size.circularBorderRadius,
- background: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ background: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
onTap: () {
if (_sendController.text == "-") {
_sendController.text = "";
@@ -935,9 +1046,9 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
padding: isDesktop
? const EdgeInsets.all(6)
: const EdgeInsets.all(2),
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonBackSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonBackSecondary,
radiusMultiplier: 0.75,
child: GestureDetector(
onTap: () async {
@@ -949,9 +1060,9 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
Assets.svg.swap,
width: 20,
height: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -970,13 +1081,13 @@ class _ExchangeFormState extends ConsumerState<ExchangeForm> {
textStyle: STextStyles.smallMed14(context).copyWith(
color: Theme.of(context).extension<StackColors>()!.textDark,
),
- buttonColor: Theme.of(
- context,
- ).extension<StackColors>()!.buttonBackSecondary,
+ buttonColor: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonBackSecondary,
borderRadius: Constants.size.circularBorderRadius,
- background: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ background: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
onTap: rateType == ExchangeRateType.estimated
? null
: () {
### lib/pages/exchange_view/exchange_step_views/step_2_view.dart
@@ -20,6 +20,7 @@ import '../../../utilities/address_utils.dart';
import '../../../utilities/barcode_scanner_interface.dart';
import '../../../utilities/clipboard_interface.dart';
import '../../../utilities/constants.dart';
+import '../../../utilities/extra_id_currency_support.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
import '../../../widgets/background.dart';
@@ -61,12 +62,42 @@ class _Step2ViewState extends ConsumerState<Step2View> {
late final TextEditingController _toController;
late final TextEditingController _refundController;
+ late final TextEditingController _toMemoController;
+ late final TextEditingController _refundMemoController;
late final FocusNode _toFocusNode;
late final FocusNode _refundFocusNode;
+ late final FocusNode _toMemoFocusNode;
+ late final FocusNode _refundMemoFocusNode;
bool enableNext = false;
+ bool get _showRecipientMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(model.receiveTicker);
+
+ bool get _showRefundMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(model.sendTicker);
+
+ void _setRecipientMemo(String? memo) {
+ // A null memo means the selected address source did not provide one. In
+ // that case keep any memo the user already entered instead of wiping it.
+ if (memo == null) return;
+ final value = _showRecipientMemo ? memo : "";
+ _toMemoController.text = value;
+ model.extraId = value.isEmpty ? null : value;
+ }
+
+ void _setRefundMemo(String? memo) {
+ // A null memo means the selected address source did not provide one. In
+ // that case keep any memo the user already entered instead of wiping it.
+ if (memo == null) return;
+ final value = _showRefundMemo ? memo : "";
+ _refundMemoController.text = value;
+ model.refundExtraId = value.isEmpty ? null : value;
+ }
+
void _onRefundQrTapped() async {
try {
final qrResult = await ref.read(pBarcodeScanner).scan(context: context);
@@ -81,6 +112,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
// auto fill address
_refundController.text = paymentData.address;
model.refundAddress = _refundController.text;
+ _setRefundMemo(paymentData.memo);
setState(() {
enableNext =
@@ -135,6 +167,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
// auto fill address
_toController.text = paymentData.address;
model.recipientAddress = _toController.text;
+ _setRecipientMemo(paymentData.memo);
setState(() {
enableNext =
@@ -150,7 +183,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
enableNext =
_toController.text.isNotEmpty &&
(_refundController.text.isNotEmpty ||
- !!ref.read(efExchangeProvider).supportsRefundAddress);
+ !ref.read(efExchangeProvider).supportsRefundAddress);
});
}
} on PlatformException catch (e, s) {
@@ -184,9 +217,15 @@ class _Step2ViewState extends ConsumerState<Step2View> {
_toController = TextEditingController();
_refundController = TextEditingController();
+ _toMemoController = TextEditingController(text: model.extraId ?? "");
+ _refundMemoController = TextEditingController(
+ text: model.refundExtraId ?? "",
+ );
_toFocusNode = FocusNode();
_refundFocusNode = FocusNode();
+ _toMemoFocusNode = FocusNode();
+ _refundMemoFocusNode = FocusNode();
final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state;
if (tuple != null) {
@@ -222,9 +261,13 @@ class _Step2ViewState extends ConsumerState<Step2View> {
void dispose() {
_toController.dispose();
_refundController.dispose();
+ _toMemoController.dispose();
+ _refundMemoController.dispose();
_toFocusNode.dispose();
_refundFocusNode.dispose();
+ _toMemoFocusNode.dispose();
+ _refundMemoFocusNode.dispose();
super.dispose();
}
@@ -436,8 +479,24 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.text!
.trim();
- _toController.text =
- content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging
+ .instance,
+ );
+ if (paymentData !=
+ null) {
+ _toController.text =
+ paymentData
+ .address;
+ _setRecipientMemo(
+ paymentData.memo,
+ );
+ } else {
+ _toController.text =
+ content;
+ }
model.recipientAddress =
_toController
.text;
@@ -543,6 +602,39 @@ class _Step2ViewState extends ConsumerState<Step2View> {
style: STextStyles.label(context),
),
),
+ if (_showRecipientMemo) const SizedBox(height: 16),
+ if (_showRecipientMemo)
+ Text(
+ "Memo or destination tag",
+ style: STextStyles.smallMed12(context),
+ ),
+ if (_showRecipientMemo) const SizedBox(height: 4),
+ if (_showRecipientMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key(
+ "recipientExchangeStep2ViewMemoFieldKey",
+ ),
+ controller: _toMemoController,
+ focusNode: _toMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ model.extraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter memo or tag if required",
+ _toMemoFocusNode,
+ context,
+ ),
+ ),
+ ),
const SizedBox(height: 24),
if (supportsRefund)
Row(
@@ -708,9 +800,27 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.text!
.trim();
- _refundController
- .text =
- content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging
+ .instance,
+ );
+ if (paymentData !=
+ null) {
+ _refundController
+ .text =
+ paymentData
+ .address;
+ _setRefundMemo(
+ paymentData
+ .memo,
+ );
+ } else {
+ _refundController
+ .text =
+ content;
+ }
model.refundAddress =
_refundController
.text;
@@ -748,44 +858,43 @@ class _Step2ViewState extends ConsumerState<Step2View> {
)
.state =
true;
- Navigator.of(context)
- .pushNamed(
- AddressBookView
- .routeName,
- )
- .then((_) {
- ref
- .read(
- exchangeFlowIsActiveStateProvider
- .state,
- )
- .state =
- false;
- final address = ref
+ Navigator.of(
+ context,
+ ).pushNamed(AddressBookView.routeName).then((
+ _,
+ ) {
+ ref
.read(
- exchangeFromAddressBookAddressStateProvider
+ exchangeFlowIsActiveStateProvider
.state,
)
- .state;
- if (address
- .isNotEmpty) {
+ .state =
+ false;
+ final address = ref
+ .read(
+ exchangeFromAddressBookAddressStateProvider
+ .state,
+ )
+ .state;
+ if (address
+ .isNotEmpty) {
+ _refundController
+ .text =
+ address;
+ model.refundAddress =
_refundController
- .text =
- address;
- model.refundAddress =
- _refundController
- .text;
- }
- setState(() {
- enableNext =
- _toController
- .text
- .isNotEmpty &&
- _refundController
- .text
- .isNotEmpty;
- });
- });
+ .text;
+ }
+ setState(() {
+ enableNext =
+ _toController
+ .text
+ .isNotEmpty &&
+ _refundController
+ .text
+ .isNotEmpty;
+ });
+ });
},
child:
const AddressBookIcon(),
@@ -815,6 +924,41 @@ class _Step2ViewState extends ConsumerState<Step2View> {
style: STextStyles.label(context),
),
),
+ if (supportsRefund && _showRefundMemo)
+ const SizedBox(height: 16),
+ if (supportsRefund && _showRefundMemo)
+ Text(
+ "Refund memo or destination tag",
+ style: STextStyles.smallMed12(context),
+ ),
+ if (supportsRefund && _showRefundMemo)
+ const SizedBox(height: 4),
+ if (supportsRefund && _showRefundMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key(
+ "refundExchangeStep2ViewMemoFieldKey",
+ ),
+ controller: _refundMemoController,
+ focusNode: _refundMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ model.refundExtraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter memo or tag if required",
+ _refundMemoFocusNode,
+ context,
+ ),
+ ),
+ ),
const SizedBox(height: 16),
const Spacer(),
Row(
### lib/pages/exchange_view/exchange_step_views/step_3_view.dart
@@ -169,6 +169,27 @@ class _Step3ViewState extends ConsumerState<Step3View> {
],
),
),
+ if (model.extraId?.isNotEmpty == true)
+ const SizedBox(height: 8),
+ if (model.extraId?.isNotEmpty == true)
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Recipient memo or tag",
+ style: STextStyles.itemSubtitle(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ model.extraId!,
+ style: STextStyles.itemSubtitle12(
+ context,
+ ),
+ ),
+ ],
+ ),
+ ),
if (supportsRefund) const SizedBox(height: 8),
if (supportsRefund)
RoundedWhiteContainer(
@@ -189,6 +210,29 @@ class _Step3ViewState extends ConsumerState<Step3View> {
],
),
),
+ if (supportsRefund &&
+ model.refundExtraId?.isNotEmpty == true)
+ const SizedBox(height: 8),
+ if (supportsRefund &&
+ model.refundExtraId?.isNotEmpty == true)
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Refund memo or tag",
+ style: STextStyles.itemSubtitle(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ model.refundExtraId!,
+ style: STextStyles.itemSubtitle12(
+ context,
+ ),
+ ),
+ ],
+ ),
+ ),
const SizedBox(height: 8),
const Spacer(),
Row(
@@ -205,14 +249,12 @@ class _Step3ViewState extends ConsumerState<Step3View> {
),
child: Text(
"Back",
- style: STextStyles.button(
- context,
- ).copyWith(
- color:
- Theme.of(context)
+ style: STextStyles.button(context)
+ .copyWith(
+ color: Theme.of(context)
.extension<StackColors>()!
.buttonTextSecondary,
- ),
+ ),
),
),
),
@@ -224,22 +266,19 @@ class _Step3ViewState extends ConsumerState<Step3View> {
showDialog<void>(
context: context,
barrierDismissible: false,
- builder:
- (_) => WillPopScope(
- onWillPop: () async => false,
- child: Container(
- color: Theme.of(context)
- .extension<StackColors>()!
- .overlay
- .withOpacity(0.6),
- child:
- const CustomLoadingOverlay(
- message:
- "Creating a trade",
- eventBus: null,
- ),
- ),
+ builder: (_) => WillPopScope(
+ onWillPop: () async => false,
+ child: Container(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .overlay
+ .withOpacity(0.6),
+ child: const CustomLoadingOverlay(
+ message: "Creating a trade",
+ eventBus: null,
),
+ ),
+ ),
),
);
@@ -256,17 +295,16 @@ class _Step3ViewState extends ConsumerState<Step3View> {
fixedRate:
model.rateType !=
ExchangeRateType.estimated,
- amount:
- model.reversed
- ? model.receiveAmount
- : model.sendAmount,
+ amount: model.reversed
+ ? model.receiveAmount
+ : model.sendAmount,
addressTo: model.recipientAddress!,
- extraId: null,
- addressRefund:
- supportsRefund
- ? model.refundAddress!
- : "",
- refundExtraId: "",
+ extraId: model.extraId,
+ addressRefund: supportsRefund
+ ? model.refundAddress!
+ : "",
+ refundExtraId:
+ model.refundExtraId ?? "",
estimate: model.estimate,
reversed: model.reversed,
);
@@ -278,8 +316,8 @@ class _Step3ViewState extends ConsumerState<Step3View> {
// TODO: better errors
String? message;
if (response.exception != null) {
- message =
- response.exception!.toString();
+ message = response.exception!
+ .toString();
if (message.startsWith(
"FormatException:",
) &&
@@ -293,12 +331,10 @@ class _Step3ViewState extends ConsumerState<Step3View> {
showDialog<void>(
context: context,
barrierDismissible: true,
- builder:
- (_) => StackDialog(
- title:
- "Failed to create trade",
- message: message ?? "",
- ),
+ builder: (_) => StackDialog(
+ title: "Failed to create trade",
+ message: message ?? "",
+ ),
),
);
}
### lib/pages/exchange_view/exchange_step_views/step_4_view.dart
@@ -133,9 +133,9 @@ class _Step4ViewState extends ConsumerState<Step4View> {
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
),
),
@@ -195,9 +195,9 @@ class _Step4ViewState extends ConsumerState<Step4View> {
return await showModalBottomSheet<bool?>(
context: context,
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Constants.size.circularBorderRadius * 3),
@@ -250,7 +250,23 @@ class _Step4ViewState extends ConsumerState<Step4View> {
final wallet = ref.read(pWallets).getWallet(tuple.item1);
- final Amount amount = model.sendAmount.toAmount(
+ final payInDecimal = model.payInDecimal;
+ if (payInDecimal == null) {
+ if (mounted) {
+ await showDialog<void>(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) => StackOkDialog(
+ title: "Invalid trade amount",
+ message:
+ "The exchange returned an invalid pay-in amount:"
+ " \"${model.payInAmount}\"",
+ ),
+ );
+ }
+ return;
+ }
+ final Amount amount = payInDecimal.toAmount(
fractionDigits: wallet.info.coin.fractionDigits,
);
final address = model.trade!.payInAddress;
@@ -368,9 +384,9 @@ class _Step4ViewState extends ConsumerState<Step4View> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
),
onPressed: () {
@@ -394,9 +410,9 @@ class _Step4ViewState extends ConsumerState<Step4View> {
},
child: Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(10),
@@ -409,9 +425,9 @@ class _Step4ViewState extends ConsumerState<Step4View> {
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
+ Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
.srcIn,
),
),
@@ -461,10 +477,10 @@ class _Step4ViewState extends ConsumerState<Step4View> {
DetailItem(
title: "Amount",
detail:
- "${model.sendAmount.toString()} "
+ "${model.payInAmount} "
"${model.sendTicker.toUpperCase()}",
button: SimpleCopyButton(
- data: model.sendAmount.toString(),
+ data: model.payInAmount,
),
),
const SizedBox(height: 8),
@@ -559,23 +575,23 @@ class _WarningInfo extends StatelessWidget {
text: TextSpan(
text:
"You must send at least "
- "${model.sendAmount.toString()} ${model.sendTicker}. ",
+ "${model.payInAmount} ${model.sendTicker}. ",
style: STextStyles.label700(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningForeground,
),
children: [
TextSpan(
text:
"If you send less than "
- "${model.sendAmount.toString()} ${model.sendTicker},"
+ "${model.payInAmount} ${model.sendTicker},"
" your transaction may not be converted and it may not be"
" refunded.",
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningForeground,
),
),
],
@@ -614,6 +630,20 @@ class _SendFromButton extends ConsumerWidget {
tuple.item2.ticker.toLowerCase()) {
await confirmSend(tuple);
} else {
+ final payInDecimal = model.payInDecimal;
+ if (payInDecimal == null) {
+ await showDialog<void>(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) => StackOkDialog(
+ title: "Invalid trade amount",
+ message:
+ "The exchange returned an invalid pay-in amount:"
+ " \"${model.payInAmount}\"",
+ ),
+ );
+ return;
+ }
await Navigator.of(context).push(
RouteGenerator.getRoute(
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
@@ -626,7 +656,7 @@ class _SendFromButton extends ConsumerWidget {
return SendFromView(
coin: coin,
- amount: model.sendAmount.toAmount(
+ amount: payInDecimal.toAmount(
fractionDigits: coin.fractionDigits,
),
address: model.trade!.payInAddress,
### lib/pages/exchange_view/sub_widgets/sorted_exchange_providers.dart
@@ -58,17 +58,16 @@ class _SortedExchangeProvidersState
}
flattened.sort((a, b) {
- if (a.$2 == null && b.$2 == null) return 1;
- if (a.$2 != null && b.$2 == null) return 0;
- if (a.$2 == null && b.$2 != null) return 0;
+ if (a.$2 != null && b.$2 != null) {
+ assert(a.$2!.reversed == b.$2!.reversed);
+ }
- // or we get problems!!!
- assert(a.$2!.reversed == b.$2!.reversed);
+ final aRate = a.$2 == null ? null : _getRate(a.$2!, amount, rcvTicker);
+ final bRate = b.$2 == null ? null : _getRate(b.$2!, amount, rcvTicker);
- return _getRate(a.$2!, amount, rcvTicker) >
- _getRate(b.$2!, amount, rcvTicker)
- ? 0
- : 1;
+ if (aRate == null) return bRate == null ? 0 : 1;
+ if (bRate == null) return -1;
+ return bRate.decimal.compareTo(aRate.decimal);
});
return flattened;
@@ -77,9 +76,8 @@ class _SortedExchangeProvidersState
Amount _getRate(Estimate e, Decimal amount, String rcvTicker) {
int decimals;
try {
- decimals = AppConfig.getCryptoCurrencyForTicker(
- rcvTicker,
- )!.fractionDigits;
+ decimals = AppConfig.getCryptoCurrencyForTicker(rcvTicker)!
+ .fractionDigits;
} catch (_) {
decimals = 8; // some reasonable alternative
}
@@ -173,9 +171,9 @@ class _SortedExchangeProvidersState
estimate: null,
pair: pair,
rateString: message ?? "Failed to fetch rate",
- rateColor: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ rateColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
);
},
);
@@ -224,9 +222,9 @@ class _SortedExchangeProvidersState
Util.isDesktop
? Container(
height: 1,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
)
: const SizedBox(height: 16),
child,
### lib/pages/masternodes/sub_widgets/register_masternode_form.dart
@@ -1,7 +1,12 @@
+import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../../providers/global/locale_provider.dart';
import '../../../providers/global/wallets_provider.dart';
+import '../../../utilities/amount/amount.dart';
+import '../../../utilities/amount/amount_field_relocalization.dart';
+import '../../../utilities/amount/amount_input_formatter.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/if_not_already.dart';
import '../../../utilities/logger.dart';
@@ -49,9 +54,9 @@ class _RegisterMasternodeFormState
TextStyle _getStyle(BuildContext context) {
return Util.isDesktop
? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
)
: STextStyles.smallMed12(context);
}
@@ -60,9 +65,17 @@ class _RegisterMasternodeFormState
bool _enableCreateButton = false;
+ // Parse as a 2-decimal Amount so pasted overprecision ("0.001") is
+ // rejected instead of silently rounding to zero basis points.
+ Decimal? get _operatorRewardPercent => Amount.tryParseEditableAmount(
+ _operatorRewardController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ fractionDigits: 2,
+ )?.decimal;
+
void _validate() {
if (mounted) {
- final percent = double.tryParse(_operatorRewardController.text);
+ final percent = _operatorRewardPercent;
setState(() {
_enableCreateButton = [
_ipAndPortController.text
@@ -72,8 +85,7 @@ class _RegisterMasternodeFormState
.length ==
2,
_operatorPubKeyController.text.trim().isNotEmpty,
- percent != null && !percent.isNegative,
- percent != null && percent <= 100.0,
+ percent != null && percent <= Decimal.fromInt(100),
_payoutAddressController.text.trim().isNotEmpty,
].every((e) => e);
});
@@ -90,11 +102,15 @@ class _RegisterMasternodeFormState
// according to https://github.com/cypherstack/stack_wallet/blob/c898a70f808ed5490b8dd23571f5f162d9e38158/lib/wallets/wallet/impl/firo_wallet.dart#L1064
// this should be a percent of 10000
- final operatorPercent = double.parse(_operatorRewardController.text);
- final operatorReward = (10000 * (operatorPercent / 100)).round().clamp(
- 0,
- 10000,
- );
+ final operatorPercent = _operatorRewardPercent;
+ if (operatorPercent == null) {
+ throw Exception("Invalid operator reward");
+ }
+ final operatorReward = (operatorPercent * Decimal.fromInt(100))
+ .round()
+ .toBigInt()
+ .toInt()
+ .clamp(0, 10000);
final wallet =
ref.read(pWallets).getWallet(widget.firoWalletId) as FiroWallet;
@@ -170,6 +186,12 @@ class _RegisterMasternodeFormState
Widget build(BuildContext context) {
final stack = Theme.of(context).extension<StackColors>()!;
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_operatorRewardController],
+ onRelocalized: _validate,
+ );
+
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -189,23 +211,20 @@ class _RegisterMasternodeFormState
children: [
Text(
"Masternode collateral",
- style: STextStyles.w500_12(
- context,
- ).copyWith(color: stack.textSubtitle1),
+ style: STextStyles.w500_12(context)
+ .copyWith(color: stack.textSubtitle1),
),
const SizedBox(height: 4),
SelectableText(
widget.collateralAddress,
- style: STextStyles.w500_14(
- context,
- ).copyWith(color: stack.textDark),
+ style: STextStyles.w500_14(context)
+ .copyWith(color: stack.textDark),
),
const SizedBox(height: 4),
SelectableText(
"${widget.collateralTxid}:${widget.collateralVout}",
- style: STextStyles.w500_12(
- context,
- ).copyWith(color: stack.textSubtitle1),
+ style: STextStyles.w500_12(context)
+ .copyWith(color: stack.textSubtitle1),
),
],
),
@@ -254,6 +273,18 @@ class _RegisterMasternodeFormState
controller: _operatorRewardController,
showPasteClearButton: true,
maxLines: 1,
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: _operatorRewardController,
+ decimals: 2,
+ locale: ref.watch(
+ localeServiceChangeNotifierProvider.select(
+ (value) => value.locale,
+ ),
+ ),
+ ),
+ ],
onChangedComprehensive: (_) => _validate(),
),
SizedBox(height: Util.isDesktop ? 24 : 16),
### lib/pages/paynym/paynym_home_view.dart
@@ -45,10 +45,7 @@ import 'subwidgets/paynym_followers_list.dart';
import 'subwidgets/paynym_following_list.dart';
class PaynymHomeView extends ConsumerStatefulWidget {
- const PaynymHomeView({
- super.key,
- required this.walletId,
- });
+ const PaynymHomeView({super.key, required this.walletId});
final String walletId;
@@ -86,10 +83,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
leading: Row(
children: [
Padding(
- padding: const EdgeInsets.only(
- left: 24,
- right: 20,
- ),
+ padding: const EdgeInsets.only(left: 24, right: 20),
child: AppBarIconButton(
size: 32,
color: Theme.of(context)
@@ -113,13 +107,8 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
height: 32,
color: Theme.of(context).extension<StackColors>()!.textDark,
),
- const SizedBox(
- width: 10,
- ),
- Text(
- "PayNym",
- style: STextStyles.desktopH3(context),
- ),
+ const SizedBox(width: 10),
+ Text("PayNym", style: STextStyles.desktopH3(context)),
],
),
trailing: kDisableFollowing
@@ -146,12 +135,13 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
);
},
child: RoundedContainer(
- padding:
- const EdgeInsets.symmetric(horizontal: 24.0),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 24.0,
+ ),
color: _followButtonHoverState
? Theme.of(context)
- .extension<StackColors>()!
- .highlight
+ .extension<StackColors>()!
+ .highlight
: Colors.transparent,
radiusMultiplier: 100,
child: Row(
@@ -164,24 +154,18 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
.extension<StackColors>()!
.textDark,
),
- const SizedBox(
- width: 8,
- ),
+ const SizedBox(width: 8),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Follow",
- style: STextStyles
- .desktopButtonSecondaryEnabled(
- context,
- ).copyWith(
- fontSize: 16,
- ),
- ),
- const SizedBox(
- height: 2,
+ style:
+ STextStyles.desktopButtonSecondaryEnabled(
+ context,
+ ).copyWith(fontSize: 16),
),
+ const SizedBox(height: 2),
],
),
],
@@ -247,22 +231,18 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
),
),
),
- const SizedBox(
- width: 4,
- ),
+ const SizedBox(width: 4),
],
),
body: ConditionalParent(
condition: !isDesktop,
builder: (child) => SafeArea(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: child,
- ),
+ child: Padding(padding: const EdgeInsets.all(16), child: child),
),
child: Column(
- crossAxisAlignment:
- isDesktop ? CrossAxisAlignment.start : CrossAxisAlignment.center,
+ crossAxisAlignment: isDesktop
+ ? CrossAxisAlignment.start
+ : CrossAxisAlignment.center,
children: [
if (!isDesktop)
Column(
@@ -281,13 +261,10 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
secretCount = 0;
}
- timer ??= Timer(
- const Duration(milliseconds: 1500),
- () {
- secretCount = 0;
- timer = null;
- },
- );
+ timer ??= Timer(const Duration(milliseconds: 1500), () {
+ secretCount = 0;
+ timer = null;
+ });
},
child: PayNymBot(
paymentCodeString: ref
@@ -297,19 +274,15 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
.code,
),
),
- const SizedBox(
- height: 10,
- ),
+ const SizedBox(height: 10),
Text(
ref
.watch(myPaynymAccountStateProvider.state)
.state!
.nymName,
style: STextStyles.desktopMenuItemSelected(context),
),
- const SizedBox(
- height: 4,
- ),
+ const SizedBox(height: 4),
Text(
Format.shorten(
ref
@@ -320,13 +293,9 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
12,
5,
),
- style: STextStyles.label(context).copyWith(
- fontSize: 14,
- ),
- ),
- const SizedBox(
- height: 11,
+ style: STextStyles.label(context).copyWith(fontSize: 14),
),
+ const SizedBox(height: 11),
Row(
children: [
Expanded(
@@ -362,9 +331,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
},
),
),
- const SizedBox(
- width: 13,
- ),
+ const SizedBox(width: 13),
Expanded(
child: SecondaryButton(
label: "Share",
@@ -379,7 +346,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
),
onPressed: () async {
Rect? sharePositionOrigin;
- if (await Util.isIPad) {
+ if (Util.isIpad == true) {
final box =
context.findRenderObject() as RenderBox?;
if (box != null) {
@@ -388,20 +355,19 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
}
}
- await Share.share(
- ref
- .read(myPaynymAccountStateProvider.state)
- .state!
- .nonSegwitPaymentCode
- .code,
- sharePositionOrigin: sharePositionOrigin,
+ await SharePlus.instance.share(
+ ShareParams(
+ text: ref
+ .read(myPaynymAccountStateProvider)!
+ .nonSegwitPaymentCode
+ .code,
+ sharePositionOrigin: sharePositionOrigin,
+ ),
);
},
),
),
- const SizedBox(
- width: 13,
- ),
+ const SizedBox(width: 13),
Expanded(
child: SecondaryButton(
label: "Address",
@@ -437,9 +403,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
padding: const EdgeInsets.all(16),
child: Row(
children: [
- const SizedBox(
- width: 4,
- ),
+ const SizedBox(width: 4),
GestureDetector(
onTap: () {
secretCount++;
@@ -469,9 +433,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
.code,
),
),
- const SizedBox(
- width: 16,
- ),
+ const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -482,9 +444,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
.nymName,
style: STextStyles.desktopH3(context),
),
- const SizedBox(
- height: 4,
- ),
+ const SizedBox(height: 4),
Text(
Format.shorten(
ref
@@ -495,8 +455,9 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
12,
5,
),
- style:
- STextStyles.desktopTextExtraExtraSmall(context),
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
],
),
@@ -532,9 +493,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
);
},
),
- const SizedBox(
- width: 16,
- ),
+ const SizedBox(width: 16),
SecondaryButton(
label: "Address",
width: 160,
@@ -561,10 +520,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
),
),
),
- if (!isDesktop)
- const SizedBox(
- height: 24,
- ),
+ if (!isDesktop) const SizedBox(height: 24),
ConditionalParent(
condition: isDesktop,
builder: (child) => Padding(
@@ -599,9 +555,7 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
),
),
),
- SizedBox(
- height: isDesktop ? 20 : 16,
- ),
+ SizedBox(height: isDesktop ? 20 : 16),
Expanded(
child: ConditionalParent(
condition: isDesktop,
@@ -610,13 +564,8 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- SizedBox(
- width: 490,
- child: child,
- ),
- const SizedBox(
- width: 24,
- ),
+ SizedBox(width: 490, child: child),
+ const SizedBox(width: 24),
if (ref
.watch(selectedPaynymDetailsItemProvider.state)
.state !=
@@ -645,24 +594,16 @@ class _PaynymHomeViewState extends ConsumerState<PaynymHomeView> {
.watch(selectedPaynymDetailsItemProvider.state)
.state !=
null)
- const SizedBox(
- width: 24,
- ),
+ const SizedBox(width: 24),
],
),
),
child: ConditionalParent(
condition: !isDesktop,
- builder: (child) => Container(
- child: child,
- ),
+ builder: (child) => Container(child: child),
child: !showFollowers
- ? PaynymFollowingList(
- walletId: widget.walletId,
- )
- : PaynymFollowersList(
- walletId: widget.walletId,
- ),
+ ? PaynymFollowingList(walletId: widget.walletId)
+ : PaynymFollowersList(walletId: widget.walletId),
),
),
),
### lib/pages/receive_view/addresses/address_card.dart
@@ -132,9 +132,12 @@ class _AddressCardState extends ConsumerState<AddressCard> {
final file = await File("${tempDir.path}/qrcode.png").create();
await file.writeAsBytes(pngBytes);
- await Share.shareFiles([
- "${tempDir.path}/qrcode.png",
- ], text: "Receive URI QR Code");
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile("${tempDir.path}/qrcode.png")],
+ text: "Receive URI QR Code",
+ ),
+ );
}
} catch (e) {
//todo: comeback to this
### lib/pages/receive_view/addresses/address_qr_popup.dart
@@ -58,16 +58,18 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
final RenderRepaintBoundary boundary =
_qrKey.currentContext?.findRenderObject() as RenderRepaintBoundary;
final ui.Image image = await boundary.toImage();
- final ByteData? byteData =
- await image.toByteData(format: ui.ImageByteFormat.png);
+ final ByteData? byteData = await image.toByteData(
+ format: ui.ImageByteFormat.png,
+ );
final Uint8List pngBytes = byteData!.buffer.asUint8List();
if (shouldSaveInsteadOfShare) {
if (isDesktop) {
final dir = Directory("${Platform.environment['HOME']}");
if (!dir.existsSync()) {
throw Exception(
- "Home dir not found while trying to open filepicker on QR image save",
+ "Home dir not found while trying to open filepicker on QR image"
+ " save",
);
}
final path = await FilePicker.platform.saveFile(
@@ -107,9 +109,11 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
final file = await File("${tempDir.path}/qrcode.png").create();
await file.writeAsBytes(pngBytes);
- await Share.shareFiles(
- ["${tempDir.path}/qrcode.png"],
- text: "Receive URI QR Code",
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile("${tempDir.path}/qrcode.png")],
+ text: "Receive URI QR Code",
+ ),
);
}
} catch (e) {
@@ -123,20 +127,10 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
return StackDialogBase(
child: Column(
children: [
- Text(
- "todo: custom label",
- style: STextStyles.pageTitleH2(context),
- ),
- const SizedBox(
- height: 8,
- ),
- Text(
- widget.addressString,
- style: STextStyles.itemSubtitle(context),
- ),
- const SizedBox(
- height: 16,
- ),
+ Text("Address", style: STextStyles.pageTitleH2(context)),
+ const SizedBox(height: 8),
+ Text(widget.addressString, style: STextStyles.itemSubtitle(context)),
+ const SizedBox(height: 16),
Center(
child: RepaintBoundary(
key: _qrKey,
@@ -150,9 +144,7 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
),
),
),
- const SizedBox(
- height: 16,
- ),
+ const SizedBox(height: 16),
Row(
children: [
Expanded(
@@ -173,9 +165,7 @@ class _AddressQrPopupState extends State<AddressQrPopup> {
),
),
),
- const SizedBox(
- width: 16,
- ),
+ const SizedBox(width: 16),
Expanded(
child: PrimaryButton(
width: 170,
### lib/pages/receive_view/generate_receiving_uri_qr_code_view.dart
@@ -14,17 +14,21 @@ import 'dart:typed_data';
import 'dart:ui' as ui;
// import 'package:document_file_save_plus/document_file_save_plus.dart';
-import 'package:decimal/decimal.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../notifications/show_flush_bar.dart';
+import '../../providers/global/locale_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
+import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
+import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/assets.dart';
import '../../utilities/clipboard_interface.dart';
import '../../utilities/constants.dart';
@@ -44,7 +48,7 @@ import '../../widgets/stack_dialog.dart';
import '../../widgets/stack_text_field.dart';
import '../../widgets/textfield_icon_button.dart';
-class GenerateUriQrCodeView extends StatefulWidget {
+class GenerateUriQrCodeView extends ConsumerStatefulWidget {
const GenerateUriQrCodeView({
super.key,
required this.coin,
@@ -59,10 +63,11 @@ class GenerateUriQrCodeView extends StatefulWidget {
final ClipboardInterface clipboard;
@override
- State<GenerateUriQrCodeView> createState() => _GenerateUriQrCodeViewState();
+ ConsumerState<GenerateUriQrCodeView> createState() =>
+ _GenerateUriQrCodeViewState();
}
-class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
+class _GenerateUriQrCodeViewState extends ConsumerState<GenerateUriQrCodeView> {
final _qrKey = GlobalKey();
late TextEditingController amountController;
@@ -90,7 +95,8 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
final dir = Directory("${Platform.environment['HOME']}");
if (!dir.existsSync()) {
throw Exception(
- "Home dir not found while trying to open filepicker on QR image save",
+ "Home dir not found while trying to open filepicker on QR image"
+ " save",
);
}
final path = await FilePicker.platform.saveFile(
@@ -122,19 +128,25 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
}
}
} else {
- // await DocumentFileSavePlus.saveFile(
- // pngBytes,
- // "receive_qr_code_${DateTime.now().toLocal().toIso8601String()}.png",
- // "image/png");
+ // await DocumentFileSavePlus.saveFile(
+ // pngBytes,
+ // "receive_qr_code_"
+ // "${DateTime.now().toLocal().toIso8601String()}"
+ // ".png",
+ // "image/png",
+ // );
}
} else {
final tempDir = await getTemporaryDirectory();
final file = await File("${tempDir.path}/qrcode.png").create();
await file.writeAsBytes(pngBytes);
- await Share.shareFiles([
- "${tempDir.path}/qrcode.png",
- ], text: "Receive URI QR Code");
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile("${tempDir.path}/qrcode.png")],
+ text: "Receive URI QR Code",
+ ),
+ );
}
} catch (e) {
//todo: comeback to this
@@ -146,16 +158,12 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
final amountString = amountController.text;
final noteString = noteController.text;
- // try "."
- Decimal? amount = Decimal.tryParse(amountString);
- if (amount == null) {
- // try single instance of ","
- final first = amountString.indexOf(",");
- final last = amountString.lastIndexOf(",");
- if (first == last) {
- amount = Decimal.tryParse(amountString.replaceFirst(",", "."));
- }
- }
+ final locale = ref.read(localeServiceChangeNotifierProvider).locale;
+ final amount = Amount.tryParseEditableAmount(
+ amountString,
+ locale: locale,
+ fractionDigits: widget.coin.fractionDigits,
+ );
if (amountString.isNotEmpty && amount == null) {
showFloatingFlushBar(
@@ -166,26 +174,18 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
return null;
}
- final Map<String, String> queryParams = {};
-
- if (amountString.isNotEmpty) {
- queryParams["amount"] = amount.toString();
- }
- if (noteString.isNotEmpty) {
- queryParams["message"] = noteString;
- }
-
String receivingAddress = widget.receivingAddress;
if ((widget.coin is Bitcoincash || widget.coin is Ecash) &&
receivingAddress.contains(":")) {
// remove cash addr prefix
receivingAddress = receivingAddress.split(":").sublist(1).join();
}
- final uriString = AddressUtils.buildUriString(
- widget.coin.uriScheme,
- receivingAddress,
- queryParams,
+ final uriString = AddressUtils.buildPaymentUriString(
+ scheme: widget.coin.uriScheme,
+ address: receivingAddress,
+ amount: amount?.decimal.toString(),
+ message: noteString,
);
Logging.instance.d("Generated receiving QR code for: $uriString");
@@ -237,10 +237,12 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
Assets.svg.share,
width: 14,
height: 14,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ colorFilter: .mode(
+ Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
+ .srcIn,
+ ),
),
onPressed: () async {
await _capturePng(false);
@@ -291,70 +293,60 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ listenForAmountRelocalization(ref.listen, controllers: [amountController]);
+
return ConditionalParent(
condition: !isDesktop,
- builder:
- (child) => Background(
- child: Scaffold(
- backgroundColor:
- Theme.of(context).extension<StackColors>()!.background,
- appBar: AppBar(
- leading: AppBarBackButton(
- onPressed: () async {
- if (FocusScope.of(context).hasFocus) {
- FocusScope.of(context).unfocus();
- await Future<void>.delayed(
- const Duration(milliseconds: 70),
- );
- }
- if (context.mounted) {
- Navigator.of(context).pop();
- }
- },
- ),
- title: Text(
- "Generate QR code",
- style: STextStyles.navBarTitle(context),
- ),
- ),
- body: SafeArea(
- child: LayoutBuilder(
- builder: (buildContext, constraints) {
- return Padding(
- padding: const EdgeInsets.only(
- left: 12,
- top: 12,
- right: 12,
+ builder: (child) => Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () async {
+ if (FocusScope.of(context).hasFocus) {
+ FocusScope.of(context).unfocus();
+ await Future<void>.delayed(const Duration(milliseconds: 70));
+ }
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+ },
+ ),
+ title: Text(
+ "Generate QR code",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (buildContext, constraints) {
+ return Padding(
+ padding: const EdgeInsets.only(left: 12, top: 12, right: 12),
+ child: SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 24,
),
- child: SingleChildScrollView(
- child: ConstrainedBox(
- constraints: BoxConstraints(
- minHeight: constraints.maxHeight - 24,
- ),
- child: IntrinsicHeight(
- child: Padding(
- padding: const EdgeInsets.all(4),
- child: child,
- ),
- ),
+ child: IntrinsicHeight(
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: child,
),
),
- );
- },
- ),
- ),
+ ),
+ ),
+ );
+ },
),
),
+ ),
+ ),
child: Padding(
- padding:
- isDesktop
- ? const EdgeInsets.only(
- top: 12,
- left: 32,
- right: 32,
- bottom: 32,
- )
- : const EdgeInsets.all(0),
+ padding: isDesktop
+ ? const EdgeInsets.only(top: 12, left: 32, right: 32, bottom: 32)
+ : const EdgeInsets.all(0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: isDesktop ? MainAxisSize.min : MainAxisSize.max,
@@ -369,17 +361,13 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
if (!isDesktop) const SizedBox(height: 12),
Text(
"Amount (Optional)",
- style:
- isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveSearchIconRight,
- )
- : STextStyles.smallMed12(context),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
+ )
+ : STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
SizedBox(height: isDesktop ? 10 : 8),
@@ -392,74 +380,75 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
enableSuggestions: Util.isDesktop ? false : true,
controller: amountController,
focusNode: _amountFocusNode,
- style:
- isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
- height: 1.8,
- )
- : STextStyles.field(context),
- keyboardType:
- Util.isDesktop
- ? null
- : const TextInputType.numberWithOptions(decimal: true),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
+ height: 1.8,
+ )
+ : STextStyles.field(context),
+ keyboardType: Util.isDesktop
+ ? null
+ : const TextInputType.numberWithOptions(decimal: true),
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: amountController,
+ decimals: widget.coin.fractionDigits,
+ locale: ref.watch(
+ localeServiceChangeNotifierProvider.select(
+ (value) => value.locale,
+ ),
+ ),
+ ),
+ ],
onChanged: (_) => setState(() {}),
- decoration: standardInputDecoration(
- "Amount",
- _amountFocusNode,
- context,
- ).copyWith(
- contentPadding:
- isDesktop
+ decoration:
+ standardInputDecoration(
+ "Amount",
+ _amountFocusNode,
+ context,
+ ).copyWith(
+ contentPadding: isDesktop
? const EdgeInsets.only(
- left: 16,
- top: 11,
- bottom: 12,
- right: 5,
- )
+ left: 16,
+ top: 11,
+ bottom: 12,
+ right: 5,
+ )
: null,
- suffixIcon:
- amountController.text.isNotEmpty
+ suffixIcon: amountController.text.isNotEmpty
? Padding(
- padding: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- amountController.text = "";
- });
- },
- ),
- ],
+ padding: const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ amountController.text = "";
+ });
+ },
+ ),
+ ],
+ ),
),
- ),
- )
+ )
: null,
- ),
+ ),
),
),
SizedBox(height: isDesktop ? 20 : 12),
Text(
"Note (Optional)",
- style:
- isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveSearchIconRight,
- )
- : STextStyles.smallMed12(context),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
+ )
+ : STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
SizedBox(height: isDesktop ? 10 : 8),
@@ -472,73 +461,67 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
enableSuggestions: Util.isDesktop ? false : true,
controller: noteController,
focusNode: _noteFocusNode,
- style:
- isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
- height: 1.8,
- )
- : STextStyles.field(context),
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
+ height: 1.8,
+ )
+ : STextStyles.field(context),
onChanged: (_) => setState(() {}),
- decoration: standardInputDecoration(
- "Note",
- _noteFocusNode,
- context,
- ).copyWith(
- contentPadding:
- isDesktop
+ decoration:
+ standardInputDecoration(
+ "Note",
+ _noteFocusNode,
+ context,
+ ).copyWith(
+ contentPadding: isDesktop
? const EdgeInsets.only(
- left: 16,
- top: 11,
- bottom: 12,
- right: 5,
- )
+ left: 16,
+ top: 11,
+ bottom: 12,
+ right: 5,
+ )
: null,
- suffixIcon:
- noteController.text.isNotEmpty
+ suffixIcon: noteController.text.isNotEmpty
? Padding(
- padding: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- noteController.text = "";
- });
- },
- ),
- ],
+ padding: const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ noteController.text = "";
+ });
+ },
+ ),
+ ],
+ ),
),
- ),
- )
+ )
: null,
- ),
+ ),
),
),
SizedBox(height: isDesktop ? 20 : 8),
PrimaryButton(
label: "Generate QR code",
- onPressed:
- isDesktop
- ? () {
- final uriString = _generateURI();
- if (uriString == null) {
- return;
- }
-
- setState(() {
- didGenerate = true;
- _uriString = uriString;
- });
+ onPressed: isDesktop
+ ? () {
+ final uriString = _generateURI();
+ if (uriString == null) {
+ return;
}
- : onGeneratePressed,
+
+ setState(() {
+ didGenerate = true;
+ _uriString = uriString;
+ });
+ }
+ : onGeneratePressed,
buttonHeight: isDesktop ? ButtonHeight.l : null,
),
if (isDesktop && didGenerate)
@@ -551,10 +534,9 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
children: [
const SizedBox(height: 20),
RoundedWhiteContainer(
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
width: isDesktop ? 370 : null,
child: Column(
children: [
@@ -575,16 +557,16 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
),
const SizedBox(height: 12),
Row(
- mainAxisAlignment:
- isDesktop
- ? MainAxisAlignment.center
- : MainAxisAlignment.start,
+ mainAxisAlignment: isDesktop
+ ? MainAxisAlignment.center
+ : MainAxisAlignment.start,
children: [
if (!isDesktop)
SecondaryButton(
width: 170,
- buttonHeight:
- isDesktop ? ButtonHeight.l : null,
+ buttonHeight: isDesktop
+ ? ButtonHeight.l
+ : null,
onPressed: () async {
await _capturePng(false);
},
@@ -593,17 +575,20 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
Assets.svg.share,
width: 20,
height: 20,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .buttonTextSecondary,
+ colorFilter: .mode(
+ Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
+ .srcIn,
+ ),
),
),
if (!isDesktop) const SizedBox(width: 16),
PrimaryButton(
width: 170,
- buttonHeight:
- isDesktop ? ButtonHeight.l : null,
+ buttonHeight: isDesktop
+ ? ButtonHeight.l
+ : null,
onPressed: () async {
// TODO: add save functionality instead of share
// save works on linux at the moment
@@ -614,10 +599,12 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
Assets.svg.arrowDown,
width: 20,
height: 20,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .buttonTextPrimary,
+ colorFilter: .mode(
+ Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextPrimary,
+ .srcIn,
+ ),
),
),
],
### lib/pages/salvium_stake/salvium_create_stake_view.dart
@@ -9,6 +9,7 @@ import '../../providers/global/wallets_provider.dart';
import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -59,7 +60,7 @@ class _SalviumCreateStakeViewState
void _parseAmount(String string) {
final cryptoAmount = ref
.read(pAmountFormatter(ref.read(pWalletCoin(widget.walletId))))
- .tryParse(string);
+ .tryParseEditable(string);
if (_amount != cryptoAmount) {
setState(() {
@@ -241,9 +242,9 @@ class _SalviumCreateStakeViewState
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -289,6 +290,7 @@ class _SalviumCreateStakeViewState
final locale = ref.watch(
localeServiceChangeNotifierProvider.select((s) => s.locale),
);
+ listenForAmountRelocalization(ref.listen, controllers: [_amountController]);
return ConditionalParent(
condition: !Util.isDesktop,
@@ -330,9 +332,9 @@ class _SalviumCreateStakeViewState
Text(
"Amount",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -357,6 +359,7 @@ class _SalviumCreateStakeViewState
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: _amountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -369,9 +372,9 @@ class _SalviumCreateStakeViewState
hintText: "0",
hintStyle: Util.isDesktop
? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
)
: STextStyles.fieldLabel(context).copyWith(fontSize: 14),
prefixIcon: FittedBox(
@@ -381,9 +384,9 @@ class _SalviumCreateStakeViewState
child: Text(
ref.watch(pAmountUnit(coin)).unitForCoin(coin),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
### lib/pages/send_view/frost_ms/frost_send_view.dart
@@ -165,9 +165,9 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -245,9 +245,9 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
condition: !Util.isDesktop,
builder: (child) => Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
@@ -319,9 +319,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
children: [
Text(
ref.watch(pWalletName(walletId)),
- style: STextStyles.titleBold12(
- context,
- ).copyWith(fontSize: 14),
+ style: STextStyles.titleBold12(context)
+ .copyWith(fontSize: 14),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
@@ -330,9 +329,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
// ),
Text(
"Available balance",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 10),
),
],
),
@@ -354,9 +352,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
.watch(pWalletBalance(walletId))
.spendable,
),
- style: STextStyles.titleBold12(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.titleBold12(context)
+ .copyWith(fontSize: 10),
textAlign: TextAlign.right,
),
],
@@ -390,9 +387,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
: () {
ref
.read(
- pRecipient(
- recipientWidgetIndexes[i],
- ).notifier,
+ pRecipient(recipientWidgetIndexes[i])
+ .notifier,
)
.state =
null;
@@ -410,9 +406,8 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
sendAllTapped: () {
return ref
.read(pAmountFormatter(coin))
- .format(
+ .formatEditable(
ref.read(pWalletBalance(walletId)).spendable,
- withUnitName: false,
);
},
),
@@ -440,9 +435,9 @@ class _FrostSendViewState extends ConsumerState<FrostSendView> {
Text(
"Coin control",
style: STextStyles.w500_14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
CustomTextButton(
### lib/pages/send_view/frost_ms/recipient.dart
@@ -1,4 +1,3 @@
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -7,6 +6,7 @@ import '../../../providers/providers.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/address_utils.dart';
import '../../../utilities/amount/amount.dart';
+import '../../../utilities/amount/amount_field_relocalization.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/amount/amount_input_formatter.dart';
import '../../../utilities/amount/amount_unit.dart';
@@ -74,7 +74,7 @@ class _RecipientState extends ConsumerState<Recipient> {
final address = addressController.text;
final amount = ref
.read(pAmountFormatter(widget.coin))
- .tryParse(amountController.text);
+ .tryParseEditable(amountController.text);
ref.read(pRecipient(widget.index).notifier).state = (
address: address,
@@ -87,7 +87,7 @@ class _RecipientState extends ConsumerState<Recipient> {
if (!_cryptoAmountChangeLock) {
Amount? cryptoAmount = ref
.read(pAmountFormatter(widget.coin))
- .tryParse(amountController.text);
+ .tryParseEditable(amountController.text);
if (cryptoAmount != null) {
if (ref.read(pRecipient(widget.index))?.amount != null &&
ref.read(pRecipient(widget.index))?.amount == cryptoAmount) {
@@ -142,12 +142,18 @@ class _RecipientState extends ConsumerState<Recipient> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: widget.coin.fractionDigits);
- amountController.text = ref
- .read(pAmountFormatter(widget.coin))
- .format(amount, withUnitName: false);
+ fractionDigits: widget.coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ amountController.text = ref
+ .read(pAmountFormatter(widget.coin))
+ .formatEditable(amount);
+ } else {
+ amountController.clear();
+ }
}
} else {
addressController.text = qrResult.rawContent!.trim();
@@ -193,7 +199,7 @@ class _RecipientState extends ConsumerState<Recipient> {
if (amount != null) {
amountController.text = ref
.read(pAmountFormatter(widget.coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
}
addressController.text = ref.read(pRecipient(widget.index))?.address ?? "";
@@ -228,6 +234,7 @@ class _RecipientState extends ConsumerState<Recipient> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(ref.listen, controllers: [amountController]);
return RoundedContainer(
color: Colors.transparent,
@@ -293,8 +300,7 @@ class _RecipientState extends ConsumerState<Recipient> {
children: [
!_addressIsEmpty
? TextFieldIconButton(
- semanticsLabel:
- "Clear Button. Clears The Address Field Input.",
+ semanticsLabel: "Clear Button. Clears The Address Field Input.",
key: const Key(
"sendViewClearAddressFieldButtonKey",
),
@@ -310,8 +316,7 @@ class _RecipientState extends ConsumerState<Recipient> {
child: const XIcon(),
)
: TextFieldIconButton(
- semanticsLabel:
- "Paste Button. Pastes From Clipboard To Address Field Input.",
+ semanticsLabel: "Paste Button. Pastes From Clipboard To Address Field Input.",
key: const Key(
"sendViewPasteAddressFieldButtonKey",
),
@@ -402,6 +407,7 @@ class _RecipientState extends ConsumerState<Recipient> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: amountController,
decimals: widget.coin.fractionDigits,
unit: ref.watch(pAmountUnit(widget.coin)),
locale: locale,
@@ -420,9 +426,9 @@ class _RecipientState extends ConsumerState<Recipient> {
.watch(pAmountUnit(widget.coin))
.unitForCoin(widget.coin),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
### lib/pages/send_view/send_view.dart
@@ -34,6 +34,7 @@ import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -170,13 +171,21 @@ class _SendViewState extends ConsumerState<SendView> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: coin.fractionDigits);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
- ref.read(pSendAmount.notifier).state = amount;
+ fractionDigits: coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ ref.read(pSendAmount.notifier).state = amount;
+ } else {
+ cryptoAmountController.clear();
+ _cachedAmountToSend = null;
+ ref.read(pSendAmount.notifier).state = null;
+ }
}
// Extract OP_RETURN data if present (for Rosen Bridge and other protocols)
@@ -392,13 +401,14 @@ class _SendViewState extends ConsumerState<SendView> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
amount = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -415,7 +425,7 @@ class _SendViewState extends ConsumerState<SendView> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text);
+ .tryParseEditable(cryptoAmountController.text);
final Amount? amount;
if (cryptoAmount != null) {
amount = cryptoAmount;
@@ -430,14 +440,17 @@ class _SendViewState extends ConsumerState<SendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (amount.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (amount.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
amount = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -482,29 +495,6 @@ class _SendViewState extends ConsumerState<SendView> {
late Amount _currentFee;
- void _setCurrentFee(String fee, bool shouldSetState) {
- fee = fee.trim();
-
- if (fee.startsWith("~")) {
- fee = fee.substring(1);
- }
- if (fee.contains(" ")) {
- fee = fee.split(" ").first;
- }
-
- final value = fee.contains(",")
- ? Decimal.parse(
- fee.replaceFirst(",", "."),
- ).toAmount(fractionDigits: coin.fractionDigits)
- : Decimal.parse(fee).toAmount(fractionDigits: coin.fractionDigits);
-
- if (shouldSetState) {
- setState(() => _currentFee = value);
- } else {
- _currentFee = value;
- }
- }
-
void _setValidAddressProviders(String? address) {
if (isPaynymSend) {
ref.read(pValidSendToAddress.notifier).state = true;
@@ -538,11 +528,11 @@ class _SendViewState extends ConsumerState<SendView> {
}
}
- late Future<String> _calculateFeesFuture;
+ late Future<Amount> _calculateFeesFuture;
- Map<Amount, String> cachedFees = {};
- Map<Amount, String> cachedFiroSparkFees = {};
- Map<Amount, String> cachedFiroPublicFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFiroSparkFees = {};
+ final Map<(Amount, FeeRateType), Amount> cachedFiroPublicFees = {};
void _setOpReturnData(String? data) {
if (!mounted) {
@@ -578,7 +568,9 @@ class _SendViewState extends ConsumerState<SendView> {
);
}
- Future<String> calculateFees(Amount amount) async {
+ Future<Amount> calculateFees(Amount amount) async {
+ final feeRateType = ref.read(feeRateTypeMobileStateProvider);
+ final cacheKey = (amount, feeRateType);
final hasOpReturnData =
isFiro &&
ref.read(publicPrivateBalanceStateProvider) == BalanceType.public &&
@@ -587,26 +579,26 @@ class _SendViewState extends ConsumerState<SendView> {
if (isFiro) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case BalanceType.public:
- if (!hasOpReturnData && cachedFiroPublicFees[amount] != null) {
- return cachedFiroPublicFees[amount]!;
+ if (!hasOpReturnData && cachedFiroPublicFees[cacheKey] != null) {
+ return cachedFiroPublicFees[cacheKey]!;
}
break;
case BalanceType.private:
- if (cachedFiroSparkFees[amount] != null) {
- return cachedFiroSparkFees[amount]!;
+ if (cachedFiroSparkFees[cacheKey] != null) {
+ return cachedFiroSparkFees[cacheKey]!;
}
break;
}
- } else if (cachedFees[amount] != null) {
- return cachedFees[amount]!;
+ } else if (cachedFees[cacheKey] != null) {
+ return cachedFees[cacheKey]!;
}
final wallet = ref.read(pWallets).getWallet(walletId);
final feeObject = await wallet.fees;
late final BigInt feeRate;
- switch (ref.read(feeRateTypeMobileStateProvider.state).state) {
+ switch (feeRateType) {
case FeeRateType.fast:
feeRate = feeObject.fast;
break;
@@ -623,7 +615,7 @@ class _SendViewState extends ConsumerState<SendView> {
Amount fee;
if (coin is CryptonoteCurrency) {
final int specialMoneroId;
- switch (ref.read(feeRateTypeMobileStateProvider.state).state) {
+ switch (feeRateType) {
case FeeRateType.fast:
specialMoneroId = (wallet as CryptonoteWallet).getTxPriorityHigh();
break;
@@ -638,11 +630,8 @@ class _SendViewState extends ConsumerState<SendView> {
}
fee = await wallet.estimateFeeFor(amount, BigInt.from(specialMoneroId));
- cachedFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees[amount]!;
+ cachedFees[cacheKey] = fee;
+ return fee;
} else if (isFiro) {
final firoWallet = wallet as FiroWallet;
@@ -654,28 +643,20 @@ class _SendViewState extends ConsumerState<SendView> {
feeRate: feeRate,
wallet: firoWallet,
);
- final formatted = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
if (!hasOpReturnData) {
- cachedFiroPublicFees[amount] = formatted;
+ cachedFiroPublicFees[cacheKey] = fee;
}
- return formatted;
+ return fee;
case BalanceType.private:
fee = await firoWallet.estimateFeeForSpark(amount);
- cachedFiroSparkFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
- return cachedFiroSparkFees[amount]!;
+ cachedFiroSparkFees[cacheKey] = fee;
+ return fee;
}
} else {
fee = await wallet.estimateFeeFor(amount, feeRate);
- cachedFees[amount] = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees[amount]!;
+ cachedFees[cacheKey] = fee;
+ return fee;
}
}
@@ -898,18 +879,17 @@ class _SendViewState extends ConsumerState<SendView> {
builder: (context) {
return StackDialog(
title: "Confirm send all",
- message:
- "You are about to send your entire balance. Would you like to continue?",
+ message: "You are about to send your entire balance. Would you like to continue?",
leftButton: TextButton(
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -967,9 +947,10 @@ class _SendViewState extends ConsumerState<SendView> {
final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
Future<TxData> txDataFuture;
+ final feeRateType = ref.read(feeRateTypeMobileStateProvider);
+ final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate);
if (isPaynymSend) {
- final feeRate = ref.read(feeRateTypeMobileStateProvider);
txDataFuture = (wallet as PaynymInterface).preparePaymentCodeSend(
txData: TxData(
paynymAccountLite: widget.accountLite!,
@@ -981,8 +962,8 @@ class _SendViewState extends ConsumerState<SendView> {
addressType: AddressType.unknown,
),
],
- satsPerVByte: isCustomFee.value ? customFeeRate : null,
- feeRateType: feeRate,
+ satsPerVByte: satsPerVByte,
+ feeRateType: feeRateType,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
@@ -1007,8 +988,8 @@ class _SendViewState extends ConsumerState<SendView> {
isChange: false,
),
],
- feeRateType: ref.read(feeRateTypeMobileStateProvider),
- satsPerVByte: isCustomFee.value ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
@@ -1027,8 +1008,8 @@ class _SendViewState extends ConsumerState<SendView> {
)!,
),
],
- feeRateType: ref.read(feeRateTypeMobileStateProvider),
- satsPerVByte: isCustomFee.value ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
@@ -1080,8 +1061,8 @@ class _SendViewState extends ConsumerState<SendView> {
addressType: wallet.cryptoCurrency.getAddressType(_address!)!,
),
],
- feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- satsPerVByte: isCustomFee.value ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
// these will need to be mweb utxos
// utxos:
@@ -1105,9 +1086,9 @@ class _SendViewState extends ConsumerState<SendView> {
),
],
memo: memo,
- feeRateType: ref.read(feeRateTypeMobileStateProvider),
- satsPerVByte: isCustomFee.value ? customFeeRate : null,
- ethEIP1559Fee: ethFee,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
+ ethEIP1559Fee: _ethFee.value,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
@@ -1182,9 +1163,9 @@ class _SendViewState extends ConsumerState<SendView> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -1255,15 +1236,14 @@ class _SendViewState extends ConsumerState<SendView> {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChanged();
}
bool get isPaynymSend => widget.accountLite != null;
- final isCustomFee = ValueNotifier(false);
int customFeeRate = 1;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
late final bool hasFees;
@@ -1277,26 +1257,25 @@ class _SendViewState extends ConsumerState<SendView> {
builder: (_) => TransactionFeeSelectionSheet(
walletId: walletId,
amount:
- (Decimal.tryParse(cryptoAmountController.text) ??
- ref.watch(pSendAmount)?.decimal ??
- Decimal.zero)
- .toAmount(fractionDigits: coin.fractionDigits),
- updateChosen: (String fee) {
- if (fee == "custom") {
- if (!isCustomFee.value) {
- setState(() {
- isCustomFee.value = true;
- });
- }
+ ref.read(pSendAmount) ??
+ Amount.zeroWith(fractionDigits: coin.fractionDigits),
+ updateChosen: (feeRateType, fee) {
+ if (feeRateType.isCustom) {
return;
}
- _setCurrentFee(fee, true);
setState(() {
- _calculateFeesFuture = Future(() => fee);
- if (isCustomFee.value) {
- isCustomFee.value = false;
+ if (fee != null) {
+ _currentFee = fee;
+ _calculateFeesFuture = Future.value(fee);
+ } else {
+ _calculateFeesFuture = calculateFees(
+ ref.read(pSendAmount) ??
+ Amount.zeroWith(fractionDigits: coin.fractionDigits),
+ );
}
+ customFeeRate = 1;
+ _ethFee.value = null;
});
},
),
@@ -1318,12 +1297,6 @@ class _SendViewState extends ConsumerState<SendView> {
ref.refresh(feeSheetSessionCacheProvider);
ref.refresh(pIsExchangeAddress);
});
- isCustomFee.addListener(() {
- if (!isCustomFee.value) {
- customFeeRate = 1;
- ethFee = null;
- }
- });
hasFees = coin is! Epiccash && coin is! NanoCurrency && coin is! Tezos;
_currentFee = 0.toAmountAsRaw(fractionDigits: coin.fractionDigits);
@@ -1354,7 +1327,7 @@ class _SendViewState extends ConsumerState<SendView> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = false;
}
sendToController.text = _data.contactLabel;
@@ -1415,6 +1388,7 @@ class _SendViewState extends ConsumerState<SendView> {
void dispose() {
_cryptoAmountChangedFeeUpdateTimer?.cancel();
_baseAmountChangedFeeUpdateTimer?.cancel();
+ _ethFee.dispose();
cryptoAmountController.removeListener(onCryptoAmountChanged);
baseAmountController.removeListener(_baseAmountChanged);
@@ -1433,18 +1407,30 @@ class _SendViewState extends ConsumerState<SendView> {
_cryptoFocus.dispose();
_baseFocus.dispose();
_memoFocus.dispose();
- isCustomFee.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom;
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final amountFormatter = ref.watch(pAmountFormatter(coin));
final balType = ref.watch(publicPrivateBalanceStateProvider);
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled =
+ ref.watch(pPreviewTxButtonEnabled(coin)) &&
+ (ref.watch(pOpReturnData) == null || balType != BalanceType.private);
+ final needsEthFee = isEth && isCustomFee;
final isMwebEnabled = ref.watch(
pWalletInfo(walletId).select((s) => s.isMwebEnabled),
@@ -1556,9 +1542,9 @@ class _SendViewState extends ConsumerState<SendView> {
children: [
Container(
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
@@ -1591,16 +1577,14 @@ class _SendViewState extends ConsumerState<SendView> {
if (isFiro || isMwebEnabled)
Text(
"${balType.name.capitalize()} balance",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 10),
),
if (coin is! Firo)
Text(
"Available balance",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 10),
),
],
),
@@ -1642,10 +1626,7 @@ class _SendViewState extends ConsumerState<SendView> {
onTap: () {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(
- amount,
- withUnitName: false,
- );
+ .formatEditable(amount);
},
child: Container(
color: Colors.transparent,
@@ -1824,8 +1805,7 @@ class _SendViewState extends ConsumerState<SendView> {
children: [
_addressToggleFlag
? TextFieldIconButton(
- semanticsLabel:
- "Clear Button. Clears The Address Field Input.",
+ semanticsLabel: "Clear Button. Clears The Address Field Input.",
key: const Key(
"sendViewClearAddressFieldButtonKey",
),
@@ -1848,8 +1828,7 @@ class _SendViewState extends ConsumerState<SendView> {
child: const XIcon(),
)
: TextFieldIconButton(
- semanticsLabel:
- "Paste Button. Pastes From Clipboard To Address Field Input.",
+ semanticsLabel: "Paste Button. Pastes From Clipboard To Address Field Input.",
key: const Key(
"sendViewPasteAddressFieldButtonKey",
),
@@ -1865,8 +1844,7 @@ class _SendViewState extends ConsumerState<SendView> {
.text
.isEmpty)
TextFieldIconButton(
- semanticsLabel:
- "Address Book Button. Opens Address Book For Address Field.",
+ semanticsLabel: "Address Book Button. Opens Address Book For Address Field.",
key: const Key(
"sendViewAddressBookButtonKey",
),
@@ -1886,8 +1864,7 @@ class _SendViewState extends ConsumerState<SendView> {
.text
.isEmpty)
TextFieldIconButton(
- semanticsLabel:
- "Scan QR Button. Opens Camera For Scanning QR Code.",
+ semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.",
key: const Key(
"sendViewScanQrButtonKey",
),
@@ -1944,8 +1921,7 @@ class _SendViewState extends ConsumerState<SendView> {
children: [
memoController.text.isNotEmpty
? TextFieldIconButton(
- semanticsLabel:
- "Clear Button. Clears The Memo Field Input.",
+ semanticsLabel: "Clear Button. Clears The Memo Field Input.",
key: const Key(
"sendViewClearMemoFieldButtonKey",
),
@@ -1957,8 +1933,7 @@ class _SendViewState extends ConsumerState<SendView> {
child: const XIcon(),
)
: TextFieldIconButton(
- semanticsLabel:
- "Paste Button. Pastes From Clipboard To Memo Field Input.",
+ semanticsLabel: "Paste Button. Pastes From Clipboard To Memo Field Input.",
key: const Key(
"sendViewPasteMemoFieldButtonKey",
),
@@ -2105,9 +2080,9 @@ class _SendViewState extends ConsumerState<SendView> {
horizontal: 12,
),
child: RawMaterialButton(
- splashColor: Theme.of(
- context,
- ).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(context)
+ .extension<StackColors>()!
+ .highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -2231,9 +2206,9 @@ class _SendViewState extends ConsumerState<SendView> {
autocorrect: Util.isDesktop ? false : true,
enableSuggestions: Util.isDesktop ? false : true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"amountInputFieldCryptoTextFieldKey",
@@ -2249,6 +2224,7 @@ class _SendViewState extends ConsumerState<SendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -2270,9 +2246,8 @@ class _SendViewState extends ConsumerState<SendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -2301,9 +2276,9 @@ class _SendViewState extends ConsumerState<SendView> {
? false
: true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"amountInputFieldFiatTextFieldKey",
@@ -2319,6 +2294,7 @@ class _SendViewState extends ConsumerState<SendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
@@ -2338,9 +2314,8 @@ class _SendViewState extends ConsumerState<SendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -2407,19 +2382,18 @@ class _SendViewState extends ConsumerState<SendView> {
}
final result =
- await Navigator.of(
- context,
- ).pushNamed(
- CoinControlView.routeName,
- arguments: Tuple4(
- walletId,
- CoinControlViewType.use,
- amount,
- selectedUTXOs
- .map((e) => e.utxo)
- .toSet(),
- ),
- );
+ await Navigator.of(context)
+ .pushNamed(
+ CoinControlView.routeName,
+ arguments: Tuple4(
+ walletId,
+ CoinControlViewType.use,
+ amount,
+ selectedUTXOs
+ .map((e) => e.utxo)
+ .toSet(),
+ ),
+ );
if (result is Set<UTXO>) {
setState(() {
@@ -2585,9 +2559,9 @@ class _SendViewState extends ConsumerState<SendView> {
horizontal: 12,
),
child: RawMaterialButton(
- splashColor: Theme.of(
- context,
- ).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(context)
+ .extension<StackColors>()!
+ .highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -2623,12 +2597,18 @@ class _SendViewState extends ConsumerState<SendView> {
ConnectionState
.done &&
snapshot.hasData) {
- _setCurrentFee(
- snapshot.data!,
- false,
- );
+ _currentFee =
+ snapshot.data!;
+ final formattedFee =
+ amountFormatter.format(
+ snapshot.data!,
+ withUnitName:
+ true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- "~${snapshot.data!}",
+ "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -2678,14 +2658,21 @@ class _SendViewState extends ConsumerState<SendView> {
.done &&
snapshot
.hasData) {
- _setCurrentFee(
- snapshot.data!,
- false,
- );
+ _currentFee =
+ snapshot.data!;
+ final formattedFee =
+ amountFormatter.format(
+ snapshot
+ .data!,
+ withUnitName:
+ true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- isCustomFee.value
+ isCustomFee
? ""
- : "~${snapshot.data!}",
+ : "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -2722,7 +2709,7 @@ class _SendViewState extends ConsumerState<SendView> {
),
],
),
- if (isCustomFee.value && !isEth)
+ if (isCustomFee && !isEth)
Padding(
padding: const EdgeInsets.only(
bottom: 12,
@@ -2735,12 +2722,15 @@ class _SendViewState extends ConsumerState<SendView> {
},
),
),
- if (isCustomFee.value && isEth)
+ if (isCustomFee && isEth)
const SizedBox(height: 12),
- if (isCustomFee.value && isEth)
+ if (isCustomFee && isEth)
EthFeeForm(
+ locale: locale,
minGasLimit: kEthereumMinGasLimit,
- stateChanged: (fee) => ethFee = fee,
+ stateChanged: (fee) {
+ _ethFee.value = fee;
+ },
),
const Spacer(),
const SizedBox(height: 12),
@@ -2759,37 +2749,45 @@ class _SendViewState extends ConsumerState<SendView> {
"bridge transaction.",
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
),
),
),
- TextButton(
- onPressed:
- ref.watch(pPreviewTxButtonEnabled(coin)) &&
- (ref.watch(pOpReturnData) == null ||
- balType != BalanceType.private)
- ? isMwcSlatepack
- ? _createSlatepack
- : isEpicSlatepack
- ? _createEpicSlatepack
- : _previewTransaction
- : null,
- style:
- ref.watch(pPreviewTxButtonEnabled(coin)) &&
- (ref.watch(pOpReturnData) == null ||
- balType != BalanceType.private)
- ? Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryEnabledButtonStyle(context)
- : Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryDisabledButtonStyle(context),
- child: Text(
- isSlatepackMode ? "Create slate" : "Preview",
- style: STextStyles.button(context),
- ),
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ (!needsEthFee || ethFee != null);
+ return TextButton(
+ onPressed: enabled
+ ? isMwcSlatepack
+ ? _createSlatepack
+ : isEpicSlatepack
+ ? _createEpicSlatepack
+ : _previewTransaction
+ : null,
+ style: enabled
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(
+ context,
+ )
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryDisabledButtonStyle(
+ context,
+ ),
+ child: Text(
+ isSlatepackMode
+ ? "Create slate"
+ : "Preview",
+ style: STextStyles.button(context),
+ ),
+ );
+ },
),
const SizedBox(height: 16),
],
### lib/pages/send_view/sol_token_send_view.dart
@@ -25,8 +25,10 @@ import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
+import '../../utilities/amount/amount_unit.dart';
import '../../utilities/assets.dart';
import '../../utilities/barcode_scanner_interface.dart';
import '../../utilities/clipboard_interface.dart';
@@ -56,6 +58,21 @@ import 'confirm_transaction_view.dart';
import 'sub_widgets/building_transaction_dialog.dart';
import 'sub_widgets/transaction_fee_selection_sheet.dart';
+Amount? parseMobileSolTokenAmount(
+ String value, {
+ required String locale,
+ required CryptoCurrency coin,
+ required SolContract tokenContract,
+}) => AmountUnit.normal.tryParse(
+ value,
+ locale: locale,
+ coin: coin,
+ tokenContract: tokenContract,
+);
+
+Amount? parseMobileSolTokenFiatAmount(String value, {required String locale}) =>
+ Amount.tryParseFiatString(value, locale: locale);
+
class SolTokenSendView extends ConsumerStatefulWidget {
const SolTokenSendView({
super.key,
@@ -109,8 +126,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
Timer? _cryptoAmountChangedFeeUpdateTimer;
Timer? _baseAmountChangedFeeUpdateTimer;
- late Future<String> _calculateFeesFuture;
- String cachedFees = "";
+ late Future<Amount> _calculateFeesFuture;
void _onTokenSendViewPasteAddressFieldButtonPressed() async {
final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
@@ -164,17 +180,22 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (paymentData.amount != null) {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet);
if (tokenWallet != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: tokenWallet.tokenDecimals);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main)))
- .format(
- amount,
- withUnitName: false,
- indicatePrecisionLoss: false,
- );
- _amountToSend = amount;
+ fractionDigits: tokenWallet.tokenDecimals,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
}
@@ -217,8 +238,19 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
}
+ String _formatTokenBalance(Amount amount) {
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ return AmountUnit.normal.displayAmount(
+ amount: amount,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: tokenWallet.cryptoCurrency,
+ maxDecimalPlaces: tokenWallet.tokenDecimals,
+ tokenContract: tokenWallet.solContract,
+ );
+ }
+
void _onFiatAmountFieldChanged(String baseAmountString) {
- final baseAmount = Amount.tryParseFiatString(
+ final baseAmount = parseMobileSolTokenFiatAmount(
baseAmountString,
locale: ref.read(localeServiceChangeNotifierProvider).locale,
);
@@ -249,12 +281,14 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
_cachedAmountToSend = _amountToSend;
_cryptoAmountChangeLock = true;
- cryptoAmountController.text = ref
- .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main)))
- .format(_amountToSend!, withUnitName: false);
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ _amountToSend!.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Amount.zero;
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -267,9 +301,12 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
final tokenWallet = ref.read(pCurrentSolanaTokenWallet);
if (tokenWallet == null) return;
- final cryptoAmount = Decimal.tryParse(
+ final cryptoAmount = parseMobileSolTokenAmount(
cryptoAmountController.text,
- )?.toAmount(fractionDigits: tokenWallet.tokenDecimals);
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: tokenWallet.cryptoCurrency,
+ tokenContract: tokenWallet.solContract,
+ );
if (cryptoAmount != null) {
_amountToSend = cryptoAmount;
if (_cachedAmountToSend != null &&
@@ -284,14 +321,17 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (_amountToSend!.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (_amountToSend!.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
_amountToSend = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -340,11 +380,16 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
(isValidAddress && amount != null && amount > Amount.zero);
}
- Future<String> calculateFees() async {
+ Future<Amount> calculateFees() async {
+ final solana = Solana(CryptoCurrencyNetwork.main);
+ final minimumFee = Amount(
+ rawValue: BigInt.from(5000),
+ fractionDigits: solana.fractionDigits,
+ );
try {
final wallet = ref.read(pCurrentSolanaTokenWallet);
if (wallet == null) {
- return "0.000005 SOL";
+ return minimumFee;
}
final feeObject = await wallet.fees;
@@ -366,19 +411,14 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
}
final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate);
- cachedFees = ref
- .read(pAmountFormatter(Solana(CryptoCurrencyNetwork.main)))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees;
+ return fee;
} catch (e, s) {
Logging.instance.w(
"Failed to calculate Solana token fees: ",
error: e,
stackTrace: s,
);
- // Return minimum fee as fallback.
- return "0.000005 SOL";
+ return minimumFee;
}
}
@@ -405,9 +445,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -523,9 +563,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -581,7 +621,15 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (_data != null) {
if (_data.amount != null) {
- cryptoAmountController.text = _data.amount!.toString();
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ final amount = Amount.fromDecimal(
+ _data.amount!,
+ fractionDigits: tokenWallet.tokenDecimals,
+ );
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
sendToController.text = _data.contactLabel;
_address = _data.address.trim();
@@ -620,6 +668,11 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
final tokenWallet = ref.watch(pCurrentSolanaTokenWallet);
@@ -635,9 +688,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (tokenWallet == null) {
return Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
@@ -695,9 +748,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
children: [
Container(
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
@@ -722,34 +775,26 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
),
Text(
"Available balance",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 10),
),
],
),
const Spacer(),
GestureDetector(
onTap: () {
- cryptoAmountController.text = ref
- .watch(
- pAmountFormatter(
- Solana(
- CryptoCurrencyNetwork.main,
- ),
- ),
+ final amount = ref
+ .read(
+ pSolanaTokenBalance((
+ walletId: widget.walletId,
+ tokenMint: tokenMint,
+ )),
)
- .format(
- ref
- .read(
- pSolanaTokenBalance((
- walletId: widget.walletId,
- tokenMint: tokenMint,
- )),
- )
- .spendable,
- withUnitName: false,
- indicatePrecisionLoss: true,
+ .spendable;
+ cryptoAmountController.text =
+ Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: locale,
);
},
child: Container(
@@ -759,27 +804,17 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
CrossAxisAlignment.end,
children: [
Text(
- ref
- .watch(
- pAmountFormatter(
- Solana(
- CryptoCurrencyNetwork
- .main,
- ),
- ),
- )
- .format(
- ref
- .watch(
- pSolanaTokenBalance((
- walletId:
- widget.walletId,
- tokenMint:
- tokenMint,
- )),
- )
- .spendable,
- ),
+ _formatTokenBalance(
+ ref
+ .watch(
+ pSolanaTokenBalance((
+ walletId:
+ widget.walletId,
+ tokenMint: tokenMint,
+ )),
+ )
+ .spendable,
+ ),
style: STextStyles.titleBold12(
context,
).copyWith(fontSize: 10),
@@ -980,8 +1015,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
children: [
memoController.text.isNotEmpty
? TextFieldIconButton(
- semanticsLabel:
- "Clear Button. Clears The Memo Field Input.",
+ semanticsLabel: "Clear Button. Clears The Memo Field Input.",
key: const Key(
"sendSolTokenViewClearMemoFieldButtonKey",
),
@@ -993,8 +1027,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
child: const XIcon(),
)
: TextFieldIconButton(
- semanticsLabel:
- "Paste Button. Pastes From Clipboard To Memo Field Input.",
+ semanticsLabel: "Paste Button. Pastes From Clipboard To Memo Field Input.",
key: const Key(
"sendSolTokenViewPasteMemoFieldButtonKey",
),
@@ -1045,9 +1078,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
autocorrect: Util.isDesktop ? false : true,
enableSuggestions: Util.isDesktop ? false : true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"solAmountInputFieldCryptoTextFieldKey",
@@ -1063,14 +1096,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenWallet.tokenDecimals,
- // TODO: Implement token-specific unit lookup
- // similar to Ethereum's pAmountUnit(coin).unitForContract(tokenContract)
- unit: ref.watch(
- pAmountUnit(
- Solana(CryptoCurrencyNetwork.main),
- ),
- ),
+ unit: AmountUnit.normal,
locale: locale,
),
],
@@ -1080,9 +1108,8 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -1109,9 +1136,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
? false
: true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"solAmountInputFieldFiatTextFieldKey",
@@ -1127,6 +1154,7 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
@@ -1138,9 +1166,8 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -1236,9 +1263,9 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
horizontal: 12,
),
child: RawMaterialButton(
- splashColor: Theme.of(
- context,
- ).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(context)
+ .extension<StackColors>()!
+ .highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -1258,21 +1285,17 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
walletId: walletId,
isToken: true,
amount:
- (Decimal.tryParse(
- cryptoAmountController
- .text,
- ) ??
- Decimal.zero)
- .toAmount(
- fractionDigits:
- tokenWallet
- .tokenDecimals,
- ),
- updateChosen: (String fee) {
+ _amountToSend ??
+ Amount.zeroWith(
+ fractionDigits: tokenWallet
+ .tokenDecimals,
+ ),
+ updateChosen: (_, fee) {
setState(() {
- _calculateFeesFuture = Future(
- () => fee,
- );
+ _calculateFeesFuture =
+ fee == null
+ ? calculateFees()
+ : Future.value(fee);
});
},
),
@@ -1303,8 +1326,21 @@ class _SolTokenSendViewState extends ConsumerState<SolTokenSendView> {
if (snapshot.connectionState ==
ConnectionState.done &&
snapshot.hasData) {
+ final formattedFee = ref
+ .watch(
+ pAmountFormatter(
+ tokenWallet
+ .cryptoCurrency,
+ ),
+ )
+ .format(
+ snapshot.data!,
+ withUnitName: true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- "~${snapshot.data!}",
+ "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
### lib/pages/send_view/sub_widgets/dual_balance_selection_sheet.dart
@@ -50,208 +50,216 @@ class _FiroBalanceSelectionSheetState
color: Theme.of(context).extension<StackColors>()!.popupBG,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
- child: Padding(
- padding: const EdgeInsets.only(left: 24, right: 24, top: 10, bottom: 0),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Center(
- child: Container(
- decoration: BoxDecoration(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: 24,
+ right: 24,
+ top: 10,
+ bottom: 16,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
),
+ width: 60,
+ height: 4,
),
- width: 60,
- height: 4,
),
- ),
- const SizedBox(height: 36),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- "Select balance",
- style: STextStyles.pageTitleH2(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () {
- final state =
- ref.read(publicPrivateBalanceStateProvider.state).state;
- if (state != BalanceType.private) {
- ref.read(publicPrivateBalanceStateProvider.state).state =
- BalanceType.private;
- }
- Navigator.of(context).pop();
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Column(
- mainAxisAlignment: MainAxisAlignment.start,
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: Radio(
- activeColor:
- Theme.of(context)
- .extension<StackColors>()!
- .radioButtonIconEnabled,
- value: BalanceType.private,
- groupValue:
- ref
- .watch(
- publicPrivateBalanceStateProvider
- .state,
- )
- .state,
- onChanged: (x) {
- ref
- .read(
+ const SizedBox(height: 36),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Select balance",
+ style: STextStyles.pageTitleH2(context),
+ textAlign: TextAlign.left,
+ ),
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () {
+ final state = ref
+ .read(publicPrivateBalanceStateProvider.state)
+ .state;
+ if (state != BalanceType.private) {
+ ref
+ .read(publicPrivateBalanceStateProvider.state)
+ .state =
+ BalanceType.private;
+ }
+ Navigator.of(context).pop();
+ },
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Column(
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: Radio(
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
+ value: BalanceType.private,
+ groupValue: ref
+ .watch(
publicPrivateBalanceStateProvider.state,
)
- .state = BalanceType.private;
+ .state,
+ onChanged: (x) {
+ ref
+ .read(
+ publicPrivateBalanceStateProvider
+ .state,
+ )
+ .state =
+ BalanceType.private;
- Navigator.of(context).pop();
- },
- ),
- ),
- ],
- ),
- const SizedBox(width: 12),
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Row(
- // children: [
- Text(
- "Private balance",
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(width: 2),
- Text(
- ref
- .watch(pAmountFormatter(coin))
- .format(
- ref
- .watch(
- coin is Firo
- ? pWalletBalanceTertiary(
- walletId,
- )
- : pWalletBalanceSecondary(
- walletId,
- ),
- )
- .spendable,
- ),
- style: STextStyles.itemSubtitle(context),
- textAlign: TextAlign.left,
+ Navigator.of(context).pop();
+ },
+ ),
),
],
),
- // ],
- // ),
- ),
- ],
+ const SizedBox(width: 12),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Row(
+ // children: [
+ Text(
+ "Private balance",
+ style: STextStyles.titleBold12(context),
+ textAlign: TextAlign.left,
+ ),
+ const SizedBox(width: 2),
+ Text(
+ ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ ref
+ .watch(
+ coin is Firo
+ ? pWalletBalanceTertiary(
+ walletId,
+ )
+ : pWalletBalanceSecondary(
+ walletId,
+ ),
+ )
+ .spendable,
+ ),
+ style: STextStyles.itemSubtitle(context),
+ textAlign: TextAlign.left,
+ ),
+ ],
+ ),
+ // ],
+ // ),
+ ),
+ ],
+ ),
),
),
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () {
- final state =
- ref.read(publicPrivateBalanceStateProvider.state).state;
- if (state != BalanceType.public) {
- ref.read(publicPrivateBalanceStateProvider.state).state =
- BalanceType.public;
- }
- Navigator.of(context).pop();
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Column(
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: Radio(
- activeColor:
- Theme.of(context)
- .extension<StackColors>()!
- .radioButtonIconEnabled,
- value: BalanceType.public,
- groupValue:
- ref
- .watch(
- publicPrivateBalanceStateProvider
- .state,
- )
- .state,
- onChanged: (x) {
- ref
- .read(
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () {
+ final state = ref
+ .read(publicPrivateBalanceStateProvider.state)
+ .state;
+ if (state != BalanceType.public) {
+ ref
+ .read(publicPrivateBalanceStateProvider.state)
+ .state =
+ BalanceType.public;
+ }
+ Navigator.of(context).pop();
+ },
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Column(
+ children: [
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: Radio(
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
+ value: BalanceType.public,
+ groupValue: ref
+ .watch(
publicPrivateBalanceStateProvider.state,
)
- .state = BalanceType.public;
- Navigator.of(context).pop();
- },
- ),
- ),
- ],
- ),
- const SizedBox(width: 12),
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Row(
- // children: [
- Text(
- "Public balance",
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(width: 2),
- Text(
- ref
- .watch(pAmountFormatter(coin))
- .format(
- ref
- .watch(pWalletBalance(walletId))
- .spendable,
- ),
- style: STextStyles.itemSubtitle(context),
- textAlign: TextAlign.left,
+ .state,
+ onChanged: (x) {
+ ref
+ .read(
+ publicPrivateBalanceStateProvider
+ .state,
+ )
+ .state =
+ BalanceType.public;
+ Navigator.of(context).pop();
+ },
+ ),
),
],
),
- ),
- ],
+ const SizedBox(width: 12),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Row(
+ // children: [
+ Text(
+ "Public balance",
+ style: STextStyles.titleBold12(context),
+ textAlign: TextAlign.left,
+ ),
+ const SizedBox(width: 2),
+ Text(
+ ref
+ .watch(pAmountFormatter(coin))
+ .format(
+ ref
+ .watch(pWalletBalance(walletId))
+ .spendable,
+ ),
+ style: STextStyles.itemSubtitle(context),
+ textAlign: TextAlign.left,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
),
),
- ),
- const SizedBox(height: 16),
- const SizedBox(height: 24),
- ],
- ),
- ],
+ ],
+ ),
+ ],
+ ),
),
),
);
### lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
@@ -22,7 +22,6 @@ import '../../../utilities/amount/amount.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/constants.dart';
import '../../../utilities/enums/fee_rate_type_enum.dart';
-import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart';
@@ -58,7 +57,7 @@ class TransactionFeeSelectionSheet extends ConsumerStatefulWidget {
final String walletId;
final Amount amount;
- final Function updateChosen;
+ final void Function(FeeRateType feeRateType, Amount? fee) updateChosen;
final bool isToken;
@override
@@ -80,6 +79,16 @@ class _TransactionFeeSelectionSheetState
"Calculating...",
];
+ void _selectFeeRate(FeeRateType feeRateType) {
+ ref.read(feeRateTypeMobileStateProvider.state).state = feeRateType;
+ widget.updateChosen(
+ feeRateType,
+ feeRateType.isCustom ? null : getAmount(feeRateType),
+ );
+
+ Navigator.of(context).pop();
+ }
+
Amount _addFiroOpReturnFee({
required Amount fee,
required BigInt feeRate,
@@ -306,468 +315,285 @@ class _TransactionFeeSelectionSheetState
color: Theme.of(context).extension<StackColors>()!.popupBG,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
- child: Padding(
- padding: const EdgeInsets.only(left: 24, right: 24, top: 10, bottom: 0),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Center(
- child: Container(
- decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: 24,
+ right: 24,
+ top: 10,
+ bottom: 0,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Center(
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
),
+ width: 60,
+ height: 4,
),
- width: 60,
- height: 4,
),
- ),
- const SizedBox(height: 36),
- FutureBuilder(
- future: widget.isToken
- ? (coin is Ethereum
- ? ref.read(pCurrentTokenWallet)!.fees
- : wallet.fees)
- : wallet.fees,
- builder: (context, AsyncSnapshot<FeeObject> snapshot) {
- if (snapshot.connectionState == ConnectionState.done &&
- snapshot.hasData) {
- feeObject = snapshot.data!;
- }
-
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- "Fee rate",
- style: STextStyles.pageTitleH2(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () {
- final state = ref
- .read(feeRateTypeMobileStateProvider.state)
- .state;
- if (state != FeeRateType.fast) {
- ref.read(feeRateTypeMobileStateProvider.state).state =
- FeeRateType.fast;
- }
- final String? fee = getAmount(
- FeeRateType.fast,
- wallet.info.coin,
- );
- if (fee != null) {
- widget.updateChosen(fee);
- }
- Navigator.of(context).pop();
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Column(
- mainAxisAlignment: MainAxisAlignment.start,
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: Radio(
- activeColor: Theme.of(context)
- .extension<StackColors>()!
- .radioButtonIconEnabled,
- value: FeeRateType.fast,
- groupValue: ref
- .watch(
- feeRateTypeMobileStateProvider.state,
- )
- .state,
- onChanged: (x) {
- ref
- .read(
- feeRateTypeMobileStateProvider
- .state,
- )
- .state =
- FeeRateType.fast;
-
- Navigator.of(context).pop();
- },
- ),
- ),
- ],
- ),
- const SizedBox(width: 12),
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ const SizedBox(height: 36),
+ FutureBuilder(
+ future: widget.isToken
+ ? (coin is Ethereum
+ ? ref.read(pCurrentTokenWallet)!.fees
+ : wallet.fees)
+ : wallet.fees,
+ builder: (context, AsyncSnapshot<FeeObject> snapshot) {
+ if (snapshot.connectionState == ConnectionState.done &&
+ snapshot.hasData) {
+ feeObject = snapshot.data!;
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Fee rate",
+ style: STextStyles.pageTitleH2(context),
+ textAlign: TextAlign.left,
+ ),
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () => _selectFeeRate(FeeRateType.fast),
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Column(
+ mainAxisAlignment: MainAxisAlignment.start,
children: [
- Row(
- children: [
- Text(
- FeeRateType.fast.prettyName,
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(width: 2),
- if (feeObject == null)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: Radio(
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
+ value: FeeRateType.fast,
+ groupValue: ref
+ .watch(
+ feeRateTypeMobileStateProvider
+ .state,
+ )
+ .state,
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.fast),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(width: 12),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ FeeRateType.fast.prettyName,
+ style: STextStyles.titleBold12(
context,
),
+ textAlign: TextAlign.left,
),
- if (feeObject != null)
- FutureBuilder(
- future: feeFor(
- coin: coin,
- feeRateType: FeeRateType.fast,
- feeRate: feeObject!.fast,
- amount: amount,
+ const SizedBox(width: 2),
+ if (feeObject == null)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
),
- builder:
- (
- _,
- AsyncSnapshot<Amount> snapshot,
- ) {
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- return Text(
- "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- textAlign: TextAlign.left,
- );
- } else {
- return AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- );
- }
- },
- ),
- ],
- ),
- const SizedBox(height: 2),
- if (feeObject == null && coin is! Ethereum)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(context),
+ if (feeObject != null)
+ FutureBuilder(
+ future: feeFor(
+ coin: coin,
+ feeRateType: FeeRateType.fast,
+ feeRate: feeObject!.fast,
+ amount: amount,
+ ),
+ builder: (_, AsyncSnapshot<Amount> snapshot) {
+ if (snapshot.connectionState ==
+ ConnectionState.done &&
+ snapshot.hasData) {
+ return Text(
+ "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ );
+ } else {
+ return AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ );
+ }
+ },
+ ),
+ ],
),
- if (feeObject != null && coin is! Ethereum)
- Text(
- estimatedTimeToBeIncludedInNextBlock(
- coin.targetBlockTimeSeconds,
- feeObject!.numberOfBlocksFast,
+ const SizedBox(height: 2),
+ if (feeObject == null && coin is! Ethereum)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
),
- style: STextStyles.itemSubtitle(context),
- textAlign: TextAlign.left,
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () {
- final state = ref
- .read(feeRateTypeMobileStateProvider.state)
- .state;
- if (state != FeeRateType.average) {
- ref.read(feeRateTypeMobileStateProvider.state).state =
- FeeRateType.average;
- }
- final String? fee = getAmount(
- FeeRateType.average,
- coin,
- );
- if (fee != null) {
- widget.updateChosen(fee);
- }
- Navigator.of(context).pop();
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Column(
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: Radio(
- activeColor: Theme.of(context)
- .extension<StackColors>()!
- .radioButtonIconEnabled,
- value: FeeRateType.average,
- groupValue: ref
- .watch(
- feeRateTypeMobileStateProvider.state,
- )
- .state,
- onChanged: (x) {
- ref
- .read(
- feeRateTypeMobileStateProvider
- .state,
- )
- .state =
- FeeRateType.average;
- Navigator.of(context).pop();
- },
- ),
- ),
- ],
- ),
- const SizedBox(width: 12),
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
+ if (feeObject != null && coin is! Ethereum)
Text(
- FeeRateType.average.prettyName,
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(width: 2),
- if (feeObject == null)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(
- context,
- ),
+ estimatedTimeToBeIncludedInNextBlock(
+ coin.targetBlockTimeSeconds,
+ feeObject!.numberOfBlocksFast,
),
- if (feeObject != null)
- FutureBuilder(
- future: feeFor(
- coin: coin,
- feeRateType: FeeRateType.average,
- feeRate: feeObject!.medium,
- amount: amount,
- ),
- builder:
- (
- _,
- AsyncSnapshot<Amount> snapshot,
- ) {
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- return Text(
- "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- textAlign: TextAlign.left,
- );
- } else {
- return AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- );
- }
- },
+ style: STextStyles.itemSubtitle(
+ context,
),
- ],
- ),
- const SizedBox(height: 2),
- if (feeObject == null && coin is! Ethereum)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(context),
- ),
- if (feeObject != null && coin is! Ethereum)
- Text(
- estimatedTimeToBeIncludedInNextBlock(
- coin.targetBlockTimeSeconds,
- feeObject!.numberOfBlocksAverage,
+ textAlign: TextAlign.left,
),
- style: STextStyles.itemSubtitle(context),
- textAlign: TextAlign.left,
- ),
- ],
+ ],
+ ),
),
- ),
- ],
+ ],
+ ),
),
),
- ),
- const SizedBox(height: 16),
- GestureDetector(
- onTap: () {
- final state = ref
- .read(feeRateTypeMobileStateProvider.state)
- .state;
- if (state != FeeRateType.slow) {
- ref.read(feeRateTypeMobileStateProvider.state).state =
- FeeRateType.slow;
- }
- final String? fee = getAmount(FeeRateType.slow, coin);
- if (fee != null) {
- widget.updateChosen(fee);
- }
- Navigator.of(context).pop();
- },
- child: Container(
- color: Colors.transparent,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Column(
- children: [
- SizedBox(
- width: 20,
- height: 20,
- child: Radio(
- activeColor: Theme.of(context)
- .extension<StackColors>()!
- .radioButtonIconEnabled,
- value: FeeRateType.slow,
- groupValue: ref
- .watch(
- feeRateTypeMobileStateProvider.state,
- )
- .state,
- onChanged: (x) {
- ref
- .read(
- feeRateTypeMobileStateProvider
- .state,
- )
- .state =
- FeeRateType.slow;
- Navigator.of(context).pop();
- },
- ),
- ),
- ],
- ),
- const SizedBox(width: 12),
- Flexible(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () => _selectFeeRate(FeeRateType.average),
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Column(
children: [
- Row(
- children: [
- Text(
- FeeRateType.slow.prettyName,
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- const SizedBox(width: 2),
- if (feeObject == null)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: Radio(
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
+ value: FeeRateType.average,
+ groupValue: ref
+ .watch(
+ feeRateTypeMobileStateProvider
+ .state,
+ )
+ .state,
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.average),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(width: 12),
+ Flexible(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ FeeRateType.average.prettyName,
+ style: STextStyles.titleBold12(
context,
),
+ textAlign: TextAlign.left,
),
- if (feeObject != null)
- FutureBuilder(
- future: feeFor(
- coin: coin,
- feeRateType: FeeRateType.slow,
- feeRate: feeObject!.slow,
- amount: amount,
+ const SizedBox(width: 2),
+ if (feeObject == null)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
),
- builder:
- (
- _,
- AsyncSnapshot<Amount> snapshot,
- ) {
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- return Text(
- "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- textAlign: TextAlign.left,
- );
- } else {
- return AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style:
- STextStyles.itemSubtitle(
- context,
- ),
- );
- }
- },
- ),
- ],
- ),
- const SizedBox(height: 2),
- if (feeObject == null && coin is! Ethereum)
- AnimatedText(
- stringsToLoopThrough:
- stringsToLoopThrough,
- style: STextStyles.itemSubtitle(context),
+ if (feeObject != null)
+ FutureBuilder(
+ future: feeFor(
+ coin: coin,
+ feeRateType: FeeRateType.average,
+ feeRate: feeObject!.medium,
+ amount: amount,
+ ),
+ builder: (_, AsyncSnapshot<Amount> snapshot) {
+ if (snapshot.connectionState ==
+ ConnectionState.done &&
+ snapshot.hasData) {
+ return Text(
+ "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ );
+ } else {
+ return AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ );
+ }
+ },
+ ),
+ ],
),
- if (feeObject != null && coin is! Ethereum)
- Text(
- estimatedTimeToBeIncludedInNextBlock(
- coin.targetBlockTimeSeconds,
- feeObject!.numberOfBlocksSlow,
+ const SizedBox(height: 2),
+ if (feeObject == null && coin is! Ethereum)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
),
- style: STextStyles.itemSubtitle(context),
- textAlign: TextAlign.left,
- ),
- ],
+ if (feeObject != null && coin is! Ethereum)
+ Text(
+ estimatedTimeToBeIncludedInNextBlock(
+ coin.targetBlockTimeSeconds,
+ feeObject!.numberOfBlocksAverage,
+ ),
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ ),
+ ],
+ ),
),
- ),
- ],
+ ],
+ ),
),
),
- ),
- const SizedBox(height: 24),
- if (wallet is ElectrumXInterface || coin is Ethereum)
+ const SizedBox(height: 16),
GestureDetector(
- onTap: () {
- final state = ref
- .read(feeRateTypeMobileStateProvider.state)
- .state;
- if (state != FeeRateType.custom) {
- ref
- .read(feeRateTypeMobileStateProvider.state)
- .state =
- FeeRateType.custom;
- }
- widget.updateChosen("custom");
-
- Navigator.of(context).pop();
- },
+ onTap: () => _selectFeeRate(FeeRateType.slow),
child: Container(
color: Colors.transparent,
child: Row(
@@ -782,23 +608,15 @@ class _TransactionFeeSelectionSheetState
activeColor: Theme.of(context)
.extension<StackColors>()!
.radioButtonIconEnabled,
- value: FeeRateType.custom,
+ value: FeeRateType.slow,
groupValue: ref
.watch(
feeRateTypeMobileStateProvider
.state,
)
.state,
- onChanged: (x) {
- ref
- .read(
- feeRateTypeMobileStateProvider
- .state,
- )
- .state =
- FeeRateType.custom;
- Navigator.of(context).pop();
- },
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.slow),
),
),
],
@@ -811,78 +629,161 @@ class _TransactionFeeSelectionSheetState
Row(
children: [
Text(
- FeeRateType.custom.prettyName,
+ FeeRateType.slow.prettyName,
style: STextStyles.titleBold12(
context,
),
textAlign: TextAlign.left,
),
+ const SizedBox(width: 2),
+ if (feeObject == null)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ if (feeObject != null)
+ FutureBuilder(
+ future: feeFor(
+ coin: coin,
+ feeRateType: FeeRateType.slow,
+ feeRate: feeObject!.slow,
+ amount: amount,
+ ),
+ builder: (_, AsyncSnapshot<Amount> snapshot) {
+ if (snapshot.connectionState ==
+ ConnectionState.done &&
+ snapshot.hasData) {
+ return Text(
+ "(~${ref.watch(pAmountFormatter(coin)).format(snapshot.data!, indicatePrecisionLoss: false)})",
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ );
+ } else {
+ return AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style:
+ STextStyles.itemSubtitle(
+ context,
+ ),
+ );
+ }
+ },
+ ),
],
),
const SizedBox(height: 2),
+ if (feeObject == null && coin is! Ethereum)
+ AnimatedText(
+ stringsToLoopThrough:
+ stringsToLoopThrough,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ if (feeObject != null && coin is! Ethereum)
+ Text(
+ estimatedTimeToBeIncludedInNextBlock(
+ coin.targetBlockTimeSeconds,
+ feeObject!.numberOfBlocksSlow,
+ ),
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ ),
],
),
),
],
),
),
),
- if (wallet is ElectrumXInterface || coin is Ethereum)
const SizedBox(height: 24),
- ],
- );
- },
- ),
- ],
+ if (wallet is ElectrumXInterface || coin is Ethereum)
+ GestureDetector(
+ onTap: () => _selectFeeRate(FeeRateType.custom),
+ child: Container(
+ color: Colors.transparent,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Column(
+ children: [
+ SizedBox(
+ width: 20,
+ height: 20,
+ child: Radio(
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
+ value: FeeRateType.custom,
+ groupValue: ref
+ .watch(
+ feeRateTypeMobileStateProvider
+ .state,
+ )
+ .state,
+ onChanged: (_) =>
+ _selectFeeRate(FeeRateType.custom),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(width: 12),
+ Flexible(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Text(
+ FeeRateType.custom.prettyName,
+ style: STextStyles.titleBold12(
+ context,
+ ),
+ textAlign: TextAlign.left,
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (wallet is ElectrumXInterface || coin is Ethereum)
+ const SizedBox(height: 24),
+ ],
+ );
+ },
+ ),
+ ],
+ ),
),
),
);
}
- String? getAmount(FeeRateType feeRateType, CryptoCurrency coin) {
- try {
- switch (feeRateType) {
- case FeeRateType.fast:
- if (ref.read(feeSheetSessionCacheProvider).fast[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).fast[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
-
- case FeeRateType.average:
- if (ref.read(feeSheetSessionCacheProvider).average[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).average[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
-
- case FeeRateType.slow:
- if (ref.read(feeSheetSessionCacheProvider).slow[amount] != null) {
- return ref
- .read(pAmountFormatter(coin))
- .format(
- ref.read(feeSheetSessionCacheProvider).slow[amount]!,
- indicatePrecisionLoss: false,
- withUnitName: false,
- );
- }
- return null;
- case FeeRateType.custom:
- return null;
- }
- } catch (e, s) {
- Logging.instance.w("$e $s", error: e, stackTrace: s);
- return null;
+ Amount? getAmount(FeeRateType feeRateType) {
+ switch (feeRateType) {
+ case FeeRateType.fast:
+ return ref.read(feeSheetSessionCacheProvider).fast[amount];
+ case FeeRateType.average:
+ return ref.read(feeSheetSessionCacheProvider).average[amount];
+ case FeeRateType.slow:
+ return ref.read(feeSheetSessionCacheProvider).slow[amount];
+ case FeeRateType.custom:
+ return null;
}
}
}
### lib/pages/send_view/token_send_view.dart
@@ -25,6 +25,7 @@ import '../../route_generator.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
+import '../../utilities/amount/amount_field_relocalization.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/amount/amount_input_formatter.dart';
import '../../utilities/amount/amount_unit.dart';
@@ -115,12 +116,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
Timer? _cryptoAmountChangedFeeUpdateTimer;
Timer? _baseAmountChangedFeeUpdateTimer;
- late Future<String> _calculateFeesFuture;
- String cachedFees = "";
+ late Future<Amount> _calculateFeesFuture;
- final isCustomFee = ValueNotifier(false);
-
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
void _onTokenSendViewPasteAddressFieldButtonPressed() async {
final ClipboardData? data = await clipboard.getData(Clipboard.kTextPlain);
@@ -187,17 +185,21 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: tokenContract.decimals);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(
- amount,
- withUnitName: false,
- indicatePrecisionLoss: false,
- );
- _amountToSend = amount;
+ fractionDigits: tokenContract.decimals,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -275,10 +277,11 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(_amountToSend!, withUnitName: false);
+ .formatEditable(_amountToSend!);
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Amount.zero;
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -295,7 +298,10 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text, tokenContract: tokenContract);
+ .tryParseEditable(
+ cryptoAmountController.text,
+ tokenContract: tokenContract,
+ );
if (cryptoAmount != null) {
_amountToSend = cryptoAmount;
if (_cachedAmountToSend != null &&
@@ -310,14 +316,17 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
?.value;
if (price != null && price > Decimal.zero) {
- baseAmountController.text = (_amountToSend!.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (_amountToSend!.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
} else {
_amountToSend = null;
+ _cachedAmountToSend = null;
baseAmountController.text = "";
}
@@ -370,7 +379,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
(isValidAddress && amount != null && amount > Amount.zero);
}
- Future<String> calculateFees() async {
+ Future<Amount> calculateFees() async {
final wallet = ref.read(pCurrentTokenWallet)!;
final feeObject = await wallet.fees;
@@ -391,11 +400,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
}
final Amount fee = await wallet.estimateFeeFor(Amount.zero, feeRate);
- cachedFees = ref
- .read(pAmountFormatter(coin))
- .format(fee, withUnitName: true, indicatePrecisionLoss: false);
-
- return cachedFees;
+ return fee;
}
Future<void> _previewTransaction() async {
@@ -501,7 +506,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
],
feeRateType: ref.read(feeRateTypeMobileStateProvider),
note: noteController.text,
- ethEIP1559Fee: ethFee,
+ ethEIP1559Fee: _ethFee.value,
),
);
@@ -553,9 +558,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
onPressed: () {
@@ -586,9 +591,6 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
@override
void initState() {
ref.refresh(feeSheetSessionCacheProvider);
- isCustomFee.addListener(() {
- if (!isCustomFee.value) ethFee = null;
- });
_calculateFeesFuture = calculateFees();
_data = widget.autoFillData;
@@ -609,7 +611,11 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (_data != null) {
if (_data.amount != null) {
- cryptoAmountController.text = _data.amount!.toString();
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(
+ _data.amount!.toAmount(fractionDigits: tokenContract.decimals),
+ );
}
sendToController.text = _data.contactLabel;
_address = _data.address.trim();
@@ -623,6 +629,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
void dispose() {
_cryptoAmountChangedFeeUpdateTimer?.cancel();
_baseAmountChangedFeeUpdateTimer?.cancel();
+ _ethFee.dispose();
cryptoAmountController.removeListener(onCryptoAmountChanged);
baseAmountController.removeListener(_baseAmountChanged);
@@ -637,16 +644,27 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
_addressFocusNode.dispose();
_cryptoFocus.dispose();
_baseFocus.dispose();
- isCustomFee.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final isCustomFee = ref.watch(feeRateTypeMobileStateProvider).isCustom;
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref
+ .watch(previewTokenTxButtonStateProvider.state)
+ .state;
+ final needsEthFee = isCustomFee;
Decimal? price;
if (ref.watch(prefsChangeNotifierProvider.select((s) => s.externalCalls))) {
@@ -696,9 +714,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
children: [
Container(
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
@@ -725,9 +743,8 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
),
Text(
"Available balance",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 10),
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 10),
),
],
),
@@ -736,7 +753,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
onTap: () {
cryptoAmountController.text = ref
.watch(pAmountFormatter(coin))
- .format(
+ .formatEditable(
ref
.read(
pTokenBalance((
@@ -746,9 +763,6 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
)),
)
.spendable,
- tokenContract: tokenContract,
- withUnitName: false,
- indicatePrecisionLoss: true,
);
},
child: Container(
@@ -977,9 +991,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
autocorrect: Util.isDesktop ? false : true,
enableSuggestions: Util.isDesktop ? false : true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"amountInputFieldCryptoTextFieldKey",
@@ -995,6 +1009,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenContract.decimals,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -1013,9 +1028,8 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -1044,9 +1058,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
? false
: true,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
key: const Key(
"amountInputFieldFiatTextFieldKey",
@@ -1062,6 +1076,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: locale,
),
@@ -1080,9 +1095,8 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
right: 12,
),
hintText: "0",
- hintStyle: STextStyles.fieldLabel(
- context,
- ).copyWith(fontSize: 14),
+ hintStyle: STextStyles.fieldLabel(context)
+ .copyWith(fontSize: 14),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
@@ -1157,7 +1171,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
),
const SizedBox(height: 12),
Text(
- "Transaction fee ${isCustomFee.value ? "" : "(max)"}",
+ "Transaction fee ${isCustomFee ? "" : "(max)"}",
style: STextStyles.smallMed12(context),
textAlign: TextAlign.left,
),
@@ -1178,9 +1192,9 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
horizontal: 12,
),
child: RawMaterialButton(
- splashColor: Theme.of(
- context,
- ).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(context)
+ .extension<StackColors>()!
+ .highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@@ -1200,33 +1214,25 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
walletId: walletId,
isToken: true,
amount:
- (Decimal.tryParse(
- cryptoAmountController
- .text,
- ) ??
- Decimal.zero)
- .toAmount(
- fractionDigits:
- tokenContract
- .decimals,
- ),
- updateChosen: (String fee) {
- if (fee == "custom") {
- if (!isCustomFee.value) {
- setState(() {
- isCustomFee.value = true;
- });
- }
+ _amountToSend ??
+ Amount.zeroWith(
+ fractionDigits:
+ tokenContract.decimals,
+ ),
+ updateChosen: (feeRateType, fee) {
+ if (feeRateType.isCustom) {
return;
}
setState(() {
- _calculateFeesFuture = Future(
- () => fee,
- );
- if (isCustomFee.value) {
- isCustomFee.value = false;
+ if (fee != null) {
+ _calculateFeesFuture =
+ Future.value(fee);
+ } else {
+ _calculateFeesFuture =
+ calculateFees();
}
+ _ethFee.value = null;
});
},
),
@@ -1257,10 +1263,20 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (snapshot.connectionState ==
ConnectionState.done &&
snapshot.hasData) {
+ final formattedFee = ref
+ .watch(
+ pAmountFormatter(coin),
+ )
+ .format(
+ snapshot.data!,
+ withUnitName: true,
+ indicatePrecisionLoss:
+ false,
+ );
return Text(
- isCustomFee.value
+ isCustomFee
? ""
- : "~${snapshot.data!}",
+ : "~$formattedFee",
style:
STextStyles.itemSubtitle(
context,
@@ -1302,39 +1318,44 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
),
],
),
- if (isCustomFee.value) const SizedBox(height: 12),
- if (isCustomFee.value)
+ if (isCustomFee) const SizedBox(height: 12),
+ if (isCustomFee)
EthFeeForm(
+ locale: locale,
minGasLimit: kEthereumTokenMinGasLimit,
- stateChanged: (value) => ethFee = value,
+ stateChanged: (value) {
+ _ethFee.value = value;
+ },
),
const Spacer(),
const SizedBox(height: 12),
- TextButton(
- onPressed:
- ref
- .watch(
- previewTokenTxButtonStateProvider.state,
- )
- .state
- ? _previewTransaction
- : null,
- style:
- ref
- .watch(
- previewTokenTxButtonStateProvider.state,
- )
- .state
- ? Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryEnabledButtonStyle(context)
- : Theme.of(context)
- .extension<StackColors>()!
- .getPrimaryDisabledButtonStyle(context),
- child: Text(
- "Preview",
- style: STextStyles.button(context),
- ),
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ (!needsEthFee || ethFee != null);
+ return TextButton(
+ onPressed: enabled
+ ? _previewTransaction
+ : null,
+ style: enabled
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryEnabledButtonStyle(
+ context,
+ )
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .getPrimaryDisabledButtonStyle(
+ context,
+ ),
+ child: Text(
+ "Preview",
+ style: STextStyles.button(context),
+ ),
+ );
+ },
),
const SizedBox(height: 16),
],
### lib/pages/settings_views/global_settings_view/about_view.dart
@@ -58,255 +58,127 @@ class AboutView extends ConsumerWidget {
children: [
FutureBuilder(
future: PackageInfo.fromPlatform(),
- builder: (
- context,
- AsyncSnapshot<PackageInfo> snapshot,
- ) {
- String version = "";
- String signature = "";
- String appName = "";
- String build = "";
+ builder:
+ (context, AsyncSnapshot<PackageInfo> snapshot) {
+ String version = "";
+ String signature = "";
+ String appName = "";
+ String build = "";
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- version = snapshot.data!.version;
- build = snapshot.data!.buildNumber;
- signature = snapshot.data!.buildSignature;
- appName = snapshot.data!.appName;
- }
+ if (snapshot.connectionState ==
+ ConnectionState.done &&
+ snapshot.hasData) {
+ version = snapshot.data!.version;
+ build = snapshot.data!.buildNumber;
+ signature = snapshot.data!.buildSignature;
+ appName = snapshot.data!.appName;
+ }
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Center(
- child: Text(
- appName,
- style: STextStyles.pageTitleH2(context),
- ),
- ),
- const SizedBox(height: 24),
- RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Version",
- style: STextStyles.titleBold12(
- context,
- ),
- ),
- const SizedBox(height: 4),
- SelectableText(
- version,
- style: STextStyles.itemSubtitle(
- context,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Build number",
- style: STextStyles.titleBold12(
- context,
- ),
- ),
- const SizedBox(height: 4),
- SelectableText(
- build,
- style: STextStyles.itemSubtitle(
- context,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Build commit",
- style: STextStyles.titleBold12(
- context,
- ),
- ),
- const SizedBox(height: 4),
- SelectableText(
- GitStatus.appCommitHash,
- style: STextStyles.itemSubtitle(
- context,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Build signature",
- style: STextStyles.titleBold12(
+ return Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Text(
+ appName,
+ style: STextStyles.pageTitleH2(
context,
),
),
- const SizedBox(height: 4),
- SelectableText(
- signature,
- style: STextStyles.itemSubtitle(
- context,
- ),
+ ),
+ const SizedBox(height: 24),
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ "Version",
+ style: STextStyles.titleBold12(
+ context,
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ version,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ ],
),
- ],
- ),
- ),
- ],
- );
- },
- ),
- if (AppConfig.coins.whereType<Epiccash>().isNotEmpty)
- const SizedBox(height: 12),
- if (AppConfig.coins.whereType<Epiccash>().isNotEmpty)
- FutureBuilder(
- future: GitStatus.getEpicCommitStatus(),
- builder: (
- context,
- AsyncSnapshot<CommitStatus> snapshot,
- ) {
- CommitStatus stateOfCommit =
- CommitStatus.notLoaded;
-
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- stateOfCommit = snapshot.data!;
- }
-
- return RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Epic Cash Build Commit",
- style: STextStyles.titleBold12(context),
),
- const SizedBox(height: 4),
- SelectableText(
- GitStatus.epicCashCommit,
- style: GitStatus.styleForStatus(
- stateOfCommit,
- context,
+ const SizedBox(height: 12),
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ "Build number",
+ style: STextStyles.titleBold12(
+ context,
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ build,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ ],
),
),
- ],
- ),
- );
- },
- ),
- const SizedBox(height: 12),
- if (AppConfig.coins
- .whereType<Mimblewimblecoin>()
- .isNotEmpty)
- const SizedBox(height: 12),
- if (AppConfig.coins
- .whereType<Mimblewimblecoin>()
- .isNotEmpty)
- FutureBuilder(
- future:
- GitStatus.getMimblewimblecoinCommitStatus(),
- builder: (
- context,
- AsyncSnapshot<CommitStatus> snapshot,
- ) {
- CommitStatus stateOfCommit =
- CommitStatus.notLoaded;
-
- if (snapshot.connectionState ==
- ConnectionState.done &&
- snapshot.hasData) {
- stateOfCommit = snapshot.data!;
- }
-
- return RoundedWhiteContainer(
- child: Column(
- crossAxisAlignment:
- CrossAxisAlignment.stretch,
- children: [
- Text(
- "Mimblewimblecoin Build Commit",
- style: STextStyles.titleBold12(context),
+ const SizedBox(height: 12),
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ "Build commit",
+ style: STextStyles.titleBold12(
+ context,
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ GitStatus.appCommitHash,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ ],
+ ),
),
- const SizedBox(height: 4),
- SelectableText(
- GitStatus.mimblewimblecoinCommit,
- style: GitStatus.styleForStatus(
- stateOfCommit,
- context,
+ const SizedBox(height: 12),
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment:
+ CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ "Build signature",
+ style: STextStyles.titleBold12(
+ context,
+ ),
+ ),
+ const SizedBox(height: 4),
+ SelectableText(
+ signature,
+ style: STextStyles.itemSubtitle(
+ context,
+ ),
+ ),
+ ],
),
),
],
- ),
- );
- },
- ),
- if (AppConfig.coins.whereType<Monero>().isNotEmpty)
- const SizedBox(height: 12),
- // if (AppConfig.coins.whereType<Monero>().isNotEmpty)
- // FutureBuilder(
- // future: GitStatus.getMoneroCommitStatus(),
- // builder: (
- // context,
- // AsyncSnapshot<CommitStatus> snapshot,
- // ) {
- // CommitStatus stateOfCommit =
- // CommitStatus.notLoaded;
- //
- // if (snapshot.connectionState ==
- // ConnectionState.done &&
- // snapshot.hasData) {
- // stateOfCommit = snapshot.data!;
- // }
- // return RoundedWhiteContainer(
- // child: Column(
- // crossAxisAlignment:
- // CrossAxisAlignment.stretch,
- // children: [
- // Text(
- // "Monero Build Commit",
- // style: STextStyles.titleBold12(context),
- // ),
- // const SizedBox(
- // height: 4,
- // ),
- // SelectableText(
- // GitStatus.moneroCommit,
- // style: GitStatus.styleForStatus(
- // stateOfCommit,
- // context,
- // ),
- // ),
- // ],
- // ),
- // );
- // },
- // ),
- // const SizedBox(
- // height: 12,
- // ),
+ );
+ },
+ ),
+ const SizedBox(height: 12),
RoundedWhiteContainer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -390,33 +262,29 @@ class AboutView extends ConsumerWidget {
TextSpan(
text: "Terms of service",
style: STextStyles.richLink(context),
- recognizer:
- TapGestureRecognizer()
- ..onTap = () {
- launchUrl(
- Uri.parse(
- "https://stackwallet.com/terms-of-service.html",
- ),
- mode:
- LaunchMode.externalApplication,
- );
- },
+ recognizer: TapGestureRecognizer()
+ ..onTap = () {
+ launchUrl(
+ Uri.parse(
+ "https://stackwallet.com/terms-of-service.html",
+ ),
+ mode: LaunchMode.externalApplication,
+ );
+ },
),
const TextSpan(text: " and "),
TextSpan(
text: "Privacy policy",
style: STextStyles.richLink(context),
- recognizer:
- TapGestureRecognizer()
- ..onTap = () {
- launchUrl(
- Uri.parse(
- "https://stackwallet.com/privacy-policy.html",
- ),
- mode:
- LaunchMode.externalApplication,
- );
- },
+ recognizer: TapGestureRecognizer()
+ ..onTap = () {
+ launchUrl(
+ Uri.parse(
+ "https://stackwallet.com/privacy-policy.html",
+ ),
+ mode: LaunchMode.externalApplication,
+ );
+ },
),
],
),
### lib/pages/settings_views/global_settings_view/advanced_views/advanced_settings_view.dart
@@ -64,9 +64,8 @@ class AdvancedSettingsView extends StatelessWidget {
),
),
onPressed: () {
- Navigator.of(
- context,
- ).pushNamed(LoggingSettingsView.routeName);
+ Navigator.of(context)
+ .pushNamed(LoggingSettingsView.routeName);
},
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -85,52 +84,55 @@ class AdvancedSettingsView extends StatelessWidget {
),
),
),
- const SizedBox(height: 8),
- RoundedWhiteContainer(
- child: Consumer(
- builder: (_, ref, __) {
- return RawMaterialButton(
- // splashColor: Theme.of(context).extension<StackColors>()!.highlight,
- materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
+ if (!AppConfig.isSingleCoinApp) const SizedBox(height: 8),
+ if (!AppConfig.isSingleCoinApp)
+ RoundedWhiteContainer(
+ child: Consumer(
+ builder: (_, ref, __) {
+ return RawMaterialButton(
+ // splashColor: Theme.of(context).extension<StackColors>()!.highlight,
+ materialTapTargetSize:
+ MaterialTapTargetSize.shrinkWrap,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
),
- ),
- onPressed: null,
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 8),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- "Toggle testnet coins",
- style: STextStyles.titleBold12(context),
- textAlign: TextAlign.left,
- ),
- SizedBox(
- height: 20,
- width: 40,
- child: DraggableSwitchButton(
- isOn: ref.watch(
- prefsChangeNotifierProvider.select(
- (value) => value.showTestNetCoins,
+ onPressed: null,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Toggle testnet coins",
+ style: STextStyles.titleBold12(context),
+ textAlign: TextAlign.left,
+ ),
+ SizedBox(
+ height: 20,
+ width: 40,
+ child: DraggableSwitchButton(
+ isOn: ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.showTestNetCoins,
+ ),
),
+ onValueChanged: (newValue) {
+ ref
+ .read(prefsChangeNotifierProvider)
+ .showTestNetCoins =
+ newValue;
+ },
),
- onValueChanged: (newValue) {
- ref
- .read(prefsChangeNotifierProvider)
- .showTestNetCoins = newValue;
- },
),
- ),
- ],
+ ],
+ ),
),
- ),
- );
- },
+ );
+ },
+ ),
),
- ),
const SizedBox(height: 8),
RoundedWhiteContainer(
child: Consumer(
@@ -165,8 +167,9 @@ class AdvancedSettingsView extends StatelessWidget {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .enableCoinControl = newValue;
+ .read(prefsChangeNotifierProvider)
+ .enableCoinControl =
+ newValue;
},
),
),
@@ -214,8 +217,9 @@ class AdvancedSettingsView extends StatelessWidget {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .enableExchange = newValue;
+ .read(prefsChangeNotifierProvider)
+ .enableExchange =
+ newValue;
},
),
),
@@ -265,13 +269,11 @@ class AdvancedSettingsView extends StatelessWidget {
style: STextStyles.titleBold12(context),
),
TextSpan(
- text:
- externalCalls
- ? "\nEasy crypto"
- : "\nIncognito",
- style: STextStyles.label(
- context,
- ).copyWith(fontSize: 15.0),
+ text: externalCalls
+ ? "\nEasy crypto"
+ : "\nIncognito",
+ style: STextStyles.label(context)
+ .copyWith(fontSize: 15.0),
),
],
),
@@ -333,9 +335,8 @@ class AdvancedSettingsView extends StatelessWidget {
),
),
onPressed: () {
- Navigator.of(
- context,
- ).pushNamed(ManageCoinUnitsView.routeName);
+ Navigator.of(context)
+ .pushNamed(ManageCoinUnitsView.routeName);
},
child: Padding(
padding: const EdgeInsets.symmetric(
### lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart
@@ -47,6 +47,7 @@ import '../../../../widgets/icon_widgets/x_icon.dart';
import '../../../../widgets/stack_dialog.dart';
import '../../../../widgets/stack_text_field.dart';
import '../../../../widgets/textfield_icon_button.dart';
+
// import 'package:web3dart/web3dart.dart';
enum AddEditNodeViewType { add, edit }
@@ -191,9 +192,9 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
child: Text(
"Cancel",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -267,9 +268,8 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
.save(node, formData.password, true);
await _notifyWalletsOfUpdatedNode();
if (mounted) {
- Navigator.of(
- context,
- ).popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete));
+ Navigator.of(context)
+ .popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete));
}
break;
case AddEditNodeViewType.edit:
@@ -297,9 +297,8 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
.save(node, formData.password, true);
await _notifyWalletsOfUpdatedNode();
if (mounted) {
- Navigator.of(
- context,
- ).popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete));
+ Navigator.of(context)
+ .popUntil(ModalRoute.withName(widget.routeOnSuccessOrDelete));
}
break;
}
@@ -474,9 +473,9 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
condition: !isDesktop,
builder: (child) => Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () async {
@@ -495,8 +494,7 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
),
actions: [
if (viewType == AddEditNodeViewType.add &&
- coin
- is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future
+ coin is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future
Padding(
padding: const EdgeInsets.only(
top: 10,
@@ -509,15 +507,15 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
key: const Key("qrNodeAppBarButtonKey"),
size: 36,
shadows: const [],
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon: QrCodeIcon(
width: 20,
height: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
onPressed: _scanQr,
),
@@ -544,14 +542,14 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
key: const Key("deleteNodeAppBarButtonKey"),
size: 36,
shadows: const [],
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon: SvgPicture.asset(
Assets.svg.trash,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
width: 20,
height: 20,
),
@@ -619,19 +617,18 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
),
],
),
- if (coin
- is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future
+ if (coin is CryptonoteCurrency) // TODO: [prio=low] do something other than `coin is CryptonoteCurrency` in the future
Padding(
padding: const EdgeInsets.only(right: 32),
child: AppBarIconButton(
size: 40,
color: isDesktop
- ? Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG
- : Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon: const QrCodeIcon(width: 21, height: 21),
onPressed: _scanQr,
),
@@ -828,16 +825,11 @@ class _NodeFormState extends ConsumerState<NodeForm> {
}
bool get canSave {
- // 65535 is max tcp port
return _nameController.text.isNotEmpty && canTestConnection;
}
bool get canTestConnection {
- // 65535 is max tcp port
- return _hostController.text.isNotEmpty &&
- port != null &&
- port! >= 0 &&
- port! <= 65535;
+ return _hostController.text.isNotEmpty && isValidNodePort(port);
}
bool enableField(TextEditingController controller) {
@@ -1545,9 +1537,9 @@ class _NodeFormState extends ConsumerState<NodeForm> {
fillColor: !widget.readOnly
? null
: MaterialStateProperty.all(
- Theme.of(
- context,
- ).extension<StackColors>()!.checkboxBGDisabled,
+ Theme.of(context)
+ .extension<StackColors>()!
+ .checkboxBGDisabled,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _forceNoTor,
@@ -1613,9 +1605,9 @@ class RadioTextButton<T> extends StatelessWidget {
width: 20,
height: 20,
child: Radio<T>(
- activeColor: Theme.of(
- context,
- ).extension<StackColors>()!.radioButtonIconEnabled,
+ activeColor: Theme.of(context)
+ .extension<StackColors>()!
+ .radioButtonIconEnabled,
value: value,
groupValue: groupValue,
onChanged: !enabled
### lib/pages/settings_views/global_settings_view/stack_backup_views/create_auto_backup_view.dart
@@ -141,7 +141,7 @@ class _EnableAutoBackupViewState extends ConsumerState<CreateAutoBackupView> {
Navigator.of(context).pop();
if (savedPath != null) {
- ref.read(prefsChangeNotifierProvider).autoBackupLocation = savedPath;
+ ref.read(prefsChangeNotifierProvider).autoBackupLocation = pathToSave;
ref.read(prefsChangeNotifierProvider).lastAutoBackup = now;
ref.read(prefsChangeNotifierProvider).isAutoBackupEnabled = true;
@@ -158,9 +158,8 @@ class _EnableAutoBackupViewState extends ConsumerState<CreateAutoBackupView> {
passwordController.text = "";
passwordRepeatController.text = "";
- Navigator.of(
- context,
- ).popUntil(ModalRoute.withName(AutoBackupView.routeName));
+ Navigator.of(context)
+ .popUntil(ModalRoute.withName(AutoBackupView.routeName));
}
} else {
await showDialog<dynamic>(
@@ -289,9 +288,9 @@ class _EnableAutoBackupViewState extends ConsumerState<CreateAutoBackupView> {
const SizedBox(width: 16),
SvgPicture.asset(
Assets.svg.folder,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
width: 16,
height: 16,
),
@@ -432,19 +431,19 @@ class _EnableAutoBackupViewState extends ConsumerState<CreateAutoBackupView> {
MediaQuery.of(context).size.width - 32 - 24,
height: 5,
fillColor: passwordStrength < 0.51
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorRed
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorRed
: passwordStrength < 1
? Theme.of(context)
.extension<StackColors>()!
.accentColorYellow
: Theme.of(context)
.extension<StackColors>()!
.accentColorGreen,
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.buttonBackSecondary,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonBackSecondary,
percent: passwordStrength < 0.25
? 0.03
: passwordStrength,
@@ -522,9 +521,9 @@ class _EnableAutoBackupViewState extends ConsumerState<CreateAutoBackupView> {
),
Positioned.fill(
child: RawMaterialButton(
- splashColor: Theme.of(
- context,
- ).extension<StackColors>()!.highlight,
+ splashColor: Theme.of(context)
+ .extension<StackColors>()!
+ .highlight,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
### lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart
@@ -1028,7 +1028,7 @@ abstract class SWB {
}
}
} else {
- final Map<String, dynamic> preNodeMap = {};
+ final Map<String, Map<String, dynamic>> preNodeMap = {};
for (final nodeData in nodes) {
preNodeMap[nodeData['id'] as String] = nodeData as Map<String, dynamic>;
}
@@ -1039,19 +1039,7 @@ abstract class SWB {
// node existed before restore attempt
// revert to pre restore node
await nodeService.save(
- node.copyWith(
- host: nodeData['host'] as String,
- port: nodeData['port'] as int,
- name: nodeData['name'] as String,
- useSSL: nodeData['useSSL'] == "false" ? false : true,
- enabled: nodeData['enabled'] == "false" ? false : true,
- coinName: nodeData['coinName'] as String,
- loginName: nodeData['loginName'] as String?,
- isFailover: nodeData['isFailover'] as bool,
- isDown: nodeData['isDown'] as bool,
- trusted: nodeData['trusted'] as bool?,
- isPrimary: nodeData["isPrimary"] as bool? ?? false,
- ),
+ NodeModel.fromStackBackup({...nodeData, 'id': node.id}),
nodeData['password'] as String?,
true,
);
@@ -1258,25 +1246,10 @@ abstract class SWB {
.toSet();
for (final node in nodes) {
- final id = node['id'] as String;
+ final nodeData = Map<String, dynamic>.from(node as Map);
await nodeService.save(
- NodeModel(
- host: node['host'] as String,
- port: node['port'] as int,
- name: node['name'] as String,
- id: id,
- useSSL: node['useSSL'] == "false" ? false : true,
- enabled: node['enabled'] == "false" ? false : true,
- coinName: node['coinName'] as String,
- loginName: node['loginName'] as String?,
- isFailover: node['isFailover'] as bool,
- isDown: node['isDown'] as bool,
- torEnabled: node['torEnabled'] as bool? ?? true,
- clearnetEnabled: node['plainEnabled'] as bool? ?? true,
- isPrimary:
- node["isPrimary"] as bool? ?? primaryIds?.contains(id) ?? false,
- ),
- node["password"] as String?,
+ NodeModel.fromStackBackup(nodeData, legacyPrimaryNodeIds: primaryIds),
+ nodeData["password"] as String?,
true,
);
}
### lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -1,4 +1,3 @@
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -74,16 +73,14 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({
Amount? amount;
if (amountStr != null && amountStr.isNotEmpty) {
- try {
- amount = Amount.fromDecimal(
- Decimal.parse(amountStr),
- fractionDigits: fractionDigits,
- );
- } catch (e, s) {
+ amount = Amount.tryParseCanonicalAmount(
+ amountStr,
+ fractionDigits: fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount == null) {
Logging.instance.e(
"Failed to parse ShopInBit payment amount '$amountStr'",
- error: e,
- stackTrace: s,
);
}
}
@@ -296,9 +293,9 @@ class ShopInBitPaymentMobileScaffold extends StatelessWidget {
}
},
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(onPressed: onBack),
title: Text("ShopinBit", style: STextStyles.navBarTitle(context)),
### lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart
@@ -8,7 +8,6 @@
*
*/
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,6 +17,7 @@ import '../../../providers/ui/transaction_filter_provider.dart';
import '../../../themes/stack_colors.dart';
import '../../../themes/theme_providers.dart';
import '../../../utilities/amount/amount.dart';
+import '../../../utilities/amount/amount_field_relocalization.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/amount/amount_input_formatter.dart';
import '../../../utilities/constants.dart';
@@ -36,6 +36,21 @@ import '../../../widgets/rounded_white_container.dart';
import '../../../widgets/stack_text_field.dart';
import '../../../widgets/textfield_icon_button.dart';
+({bool isValid, Amount? amount}) parseTransactionFilterAmountInput({
+ required String text,
+ required String locale,
+ required AmountFormatter formatter,
+}) {
+ final decimalSeparator =
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+ if (text.isEmpty || text == decimalSeparator) {
+ return (isValid: true, amount: null);
+ }
+
+ final amount = formatter.tryParseEditable(text);
+ return (isValid: amount != null, amount: amount);
+}
+
class TransactionSearchFilterView extends ConsumerStatefulWidget {
const TransactionSearchFilterView({super.key, required this.coin});
@@ -77,7 +92,7 @@ class _TransactionSearchViewState
? ""
: ref
.read(pAmountFormatter(widget.coin))
- .format(filterState.amount!, withUnitName: false);
+ .formatEditable(filterState.amount!);
_amountTextEditingController.text = amount;
}
@@ -100,6 +115,10 @@ class _TransactionSearchViewState
@override
Widget build(BuildContext context) {
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [_amountTextEditingController],
+ );
if (Util.isDesktop) {
return DesktopDialog(
maxWidth: 576,
@@ -112,13 +131,13 @@ class _TransactionSearchViewState
} else {
return Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
leading: AppBarBackButton(
onPressed: () async {
if (FocusScope.of(context).hasFocus) {
@@ -414,6 +433,7 @@ class _TransactionSearchViewState
),
inputFormatters: [
AmountInputFormatter(
+ controller: _amountTextEditingController,
decimals: widget.coin.fractionDigits,
unit: ref.watch(pAmountUnit(widget.coin)),
locale: ref.watch(
@@ -431,9 +451,9 @@ class _TransactionSearchViewState
],
style: isDesktop
? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
height: 1.8,
)
: STextStyles.field(context),
@@ -500,9 +520,9 @@ class _TransactionSearchViewState
focusNode: keywordTextFieldFocusNode,
style: isDesktop
? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
height: 1.8,
)
: STextStyles.field(context),
@@ -629,16 +649,14 @@ class _TransactionSearchViewState
}
Future<void> _onApplyPressed() async {
- final amountText = _amountTextEditingController.text;
- Amount? amount;
- if (amountText.isNotEmpty && !(amountText == "," || amountText == ".")) {
- amount = amountText.contains(",")
- ? Decimal.parse(
- amountText.replaceFirst(",", "."),
- ).toAmount(fractionDigits: widget.coin.fractionDigits)
- : Decimal.parse(
- amountText,
- ).toAmount(fractionDigits: widget.coin.fractionDigits);
+ final locale = ref.read(localeServiceChangeNotifierProvider).locale;
+ final parsedAmount = parseTransactionFilterAmountInput(
+ text: _amountTextEditingController.text,
+ locale: locale,
+ formatter: ref.read(pAmountFormatter(widget.coin)),
+ );
+ if (!parsedAmount.isValid) {
+ return;
}
final TransactionFilter filter = TransactionFilter(
@@ -647,7 +665,7 @@ class _TransactionSearchViewState
trade: _isActiveTradeCheckbox,
from: _selectedFromDate,
to: _selectedToDate,
- amount: amount,
+ amount: parsedAmount.amount,
keyword: _keywordTextEditingController.text,
);
### lib/pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart
@@ -18,6 +18,7 @@ import 'package:isar_community/isar.dart';
import '../../db/isar/main_db.dart';
import '../../models/input.dart';
import '../../models/isar/models/blockchain_data/utxo.dart';
+import '../../providers/global/locale_provider.dart';
import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
@@ -108,13 +109,18 @@ class _DesktopCoinControlUseDialogState
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+
if (_sort == CCSortDescriptor.address) {
_list = null;
_map = MainDB.instance.queryUTXOsGroupedByAddressSync(
walletId: widget.walletId,
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
} else {
@@ -124,6 +130,7 @@ class _DesktopCoinControlUseDialogState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
}
@@ -132,18 +139,16 @@ class _DesktopCoinControlUseDialogState
.map((e) => e.value)
.fold(
Amount(rawValue: BigInt.zero, fractionDigits: coin.fractionDigits),
- (value, element) =>
- value += Amount(
- rawValue: BigInt.from(element),
- fractionDigits: coin.fractionDigits,
- ),
+ (value, element) => value += Amount(
+ rawValue: BigInt.from(element),
+ fractionDigits: coin.fractionDigits,
+ ),
);
- final enableApply =
- widget.amountToSend == null
- ? selectedChanged(_selectedUTXOs)
- : selectedChanged(_selectedUTXOs) &&
- widget.amountToSend! <= selectedSum;
+ final enableApply = widget.amountToSend == null
+ ? selectedChanged(_selectedUTXOs)
+ : selectedChanged(_selectedUTXOs) &&
+ widget.amountToSend! <= selectedSum;
return DesktopDialog(
maxWidth: 700,
@@ -163,10 +168,9 @@ class _DesktopCoinControlUseDialogState
children: [
RoundedContainer(
color: Colors.transparent,
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -198,56 +202,55 @@ class _DesktopCoinControlUseDialogState
_searchString = value;
});
},
- style: STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(context)
+ style: STextStyles.desktopTextExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveText,
- height: 1.8,
- ),
- decoration: standardInputDecoration(
- "Search...",
- searchFieldFocusNode,
- context,
- desktopMed: true,
- ).copyWith(
- prefixIcon: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 18,
- ),
- child: SvgPicture.asset(
- Assets.svg.search,
- width: 20,
- height: 20,
+ height: 1.8,
),
- ),
- suffixIcon:
- _searchController.text.isNotEmpty
+ decoration:
+ standardInputDecoration(
+ "Search...",
+ searchFieldFocusNode,
+ context,
+ desktopMed: true,
+ ).copyWith(
+ prefixIcon: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 18,
+ ),
+ child: SvgPicture.asset(
+ Assets.svg.search,
+ width: 20,
+ height: 20,
+ ),
+ ),
+ suffixIcon: _searchController.text.isNotEmpty
? Padding(
- padding: const EdgeInsets.only(
- right: 0,
- ),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- _searchController.text = "";
- _searchString = "";
- });
- },
- ),
- ],
+ padding: const EdgeInsets.only(
+ right: 0,
),
- ),
- )
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ _searchController.text =
+ "";
+ _searchString = "";
+ });
+ },
+ ),
+ ],
+ ),
+ ),
+ )
: null,
- ),
+ ),
),
),
),
@@ -257,14 +260,12 @@ class _DesktopCoinControlUseDialogState
width: 240,
child: Toggle(
isOn: _filter == CCFilter.frozen,
- onColor:
- Theme.of(context)
- .extension<StackColors>()!
- .rateTypeToggleDesktopColorOn,
- offColor:
- Theme.of(context)
- .extension<StackColors>()!
- .rateTypeToggleDesktopColorOff,
+ onColor: Theme.of(context)
+ .extension<StackColors>()!
+ .rateTypeToggleDesktopColorOn,
+ offColor: Theme.of(context)
+ .extension<StackColors>()!
+ .rateTypeToggleDesktopColorOff,
onIcon: Assets.svg.coinControl.unBlocked,
onText: "Available",
offIcon: Assets.svg.coinControl.blocked,
@@ -303,167 +304,151 @@ class _DesktopCoinControlUseDialogState
),
const SizedBox(height: 16),
Expanded(
- child:
- _list != null
- ? ListView.separated(
- shrinkWrap: true,
- primary: false,
- itemCount: _list!.length,
- separatorBuilder:
- (context, _) => const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(_list![index])
- .findFirstSync()!;
- final data = UtxoRowData(utxo.id, false);
- data.selected = _selectedUTXOsData.contains(
- data,
- );
-
- return UtxoRow(
- key: Key(
- "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
- ),
- data: data,
- compact: true,
- walletId: widget.walletId,
- onSelectionChanged: (value) {
- setState(() {
- if (data.selected) {
- _selectedUTXOsData.add(value);
- _selectedUTXOs.add(utxo);
- } else {
- _selectedUTXOsData.remove(value);
- _selectedUTXOs.remove(utxo);
- }
- });
- },
- );
- },
- )
- : ListView.separated(
- itemCount: _map!.entries.length,
- separatorBuilder:
- (context, _) => const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final entry = _map!.entries.elementAt(index);
- final _controller = RotateIconController();
+ child: _list != null
+ ? ListView.separated(
+ shrinkWrap: true,
+ primary: false,
+ itemCount: _list!.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(_list![index])
+ .findFirstSync()!;
+ final data = UtxoRowData(utxo.id, false);
+ data.selected = _selectedUTXOsData.contains(data);
- return Expandable2(
- border:
- Theme.of(context)
- .extension<StackColors>()!
- .backgroundAppBar,
- background:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- onExpandWillChange: (state) {
- if (state == Expandable2State.expanded) {
- _controller.forward?.call();
+ return UtxoRow(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
+ ),
+ data: data,
+ compact: true,
+ walletId: widget.walletId,
+ onSelectionChanged: (value) {
+ setState(() {
+ if (data.selected) {
+ _selectedUTXOsData.add(value);
+ _selectedUTXOs.add(utxo);
} else {
- _controller.reverse?.call();
+ _selectedUTXOsData.remove(value);
+ _selectedUTXOs.remove(utxo);
}
- },
- header: RoundedContainer(
- padding: const EdgeInsets.all(20),
- color: Colors.transparent,
- child: Row(
- children: [
- SvgPicture.file(
- File(
- ref.watch(coinIconProvider(coin)),
- ),
- width: 24,
- height: 24,
- ),
- const SizedBox(width: 12),
- Expanded(
- flex: 3,
- child: Text(
- entry.key,
- style: STextStyles.w600_14(context),
- ),
+ });
+ },
+ );
+ },
+ )
+ : ListView.separated(
+ itemCount: _map!.entries.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final entry = _map!.entries.elementAt(index);
+ final _controller = RotateIconController();
+
+ return Expandable2(
+ border: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
+ background: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
+ animationDurationMultiplier:
+ 0.2 * entry.value.length,
+ onExpandWillChange: (state) {
+ if (state == Expandable2State.expanded) {
+ _controller.forward?.call();
+ } else {
+ _controller.reverse?.call();
+ }
+ },
+ header: RoundedContainer(
+ padding: const EdgeInsets.all(20),
+ color: Colors.transparent,
+ child: Row(
+ children: [
+ SvgPicture.file(
+ File(ref.watch(coinIconProvider(coin))),
+ width: 24,
+ height: 24,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ flex: 3,
+ child: Text(
+ entry.key,
+ style: STextStyles.w600_14(context),
),
- Expanded(
- child: Text(
- "${entry.value.length} "
- "output${entry.value.length > 1 ? "s" : ""}",
- style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- ),
+ ),
+ Expanded(
+ child: Text(
+ "${entry.value.length} "
+ "output${entry.value.length > 1 ? "s" : ""}",
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
- RotateIcon(
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- icon: SvgPicture.asset(
- Assets.svg.chevronDown,
- width: 14,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
- ),
- curve: Curves.easeInOut,
- controller: _controller,
+ ),
+ RotateIcon(
+ animationDurationMultiplier:
+ 0.2 * entry.value.length,
+ icon: SvgPicture.asset(
+ Assets.svg.chevronDown,
+ width: 14,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
- ],
- ),
+ curve: Curves.easeInOut,
+ controller: _controller,
+ ),
+ ],
),
- children:
- entry.value.map((id) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(id)
- .findFirstSync()!;
- final data = UtxoRowData(
- utxo.id,
- false,
- );
- data.selected = _selectedUTXOsData
- .contains(data);
+ ),
+ children: entry.value.map((id) {
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(id)
+ .findFirstSync()!;
+ final data = UtxoRowData(utxo.id, false);
+ data.selected = _selectedUTXOsData.contains(
+ data,
+ );
- return UtxoRow(
- key: Key(
- "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
- ),
- data: data,
- compact: true,
- compactWithBorder: false,
- raiseOnSelected: false,
- walletId: widget.walletId,
- onSelectionChanged: (value) {
- setState(() {
- if (data.selected) {
- _selectedUTXOsData.add(value);
- _selectedUTXOs.add(utxo);
- } else {
- _selectedUTXOsData.remove(
- value,
- );
- _selectedUTXOs.remove(utxo);
- }
- });
- },
- );
- }).toList(),
- );
- },
- ),
+ return UtxoRow(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
+ ),
+ data: data,
+ compact: true,
+ compactWithBorder: false,
+ raiseOnSelected: false,
+ walletId: widget.walletId,
+ onSelectionChanged: (value) {
+ setState(() {
+ if (data.selected) {
+ _selectedUTXOsData.add(value);
+ _selectedUTXOs.add(utxo);
+ } else {
+ _selectedUTXOsData.remove(value);
+ _selectedUTXOs.remove(utxo);
+ }
+ });
+ },
+ );
+ }).toList(),
+ );
+ },
+ ),
),
const SizedBox(height: 16),
RoundedContainer(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
padding: EdgeInsets.zero,
child: ConditionalParent(
condition: widget.amountToSend != null,
@@ -474,10 +459,9 @@ class _DesktopCoinControlUseDialogState
child,
Container(
height: 1.2,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
),
Padding(
padding: const EdgeInsets.all(16),
@@ -491,10 +475,9 @@ class _DesktopCoinControlUseDialogState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
),
SelectableText(
@@ -504,10 +487,9 @@ class _DesktopCoinControlUseDialogState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
),
),
],
@@ -523,33 +505,33 @@ class _DesktopCoinControlUseDialogState
children: [
Text(
"Selected amount",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
- ),
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
SelectableText(
"${selectedSum.decimal.toStringAsFixed(coin.fractionDigits)} ${coin.ticker}",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- widget.amountToSend == null
- ? Theme.of(
- context,
- ).extension<StackColors>()!.textDark
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: widget.amountToSend == null
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .textDark
: selectedSum < widget.amountToSend!
? Theme.of(context)
- .extension<StackColors>()!
- .accentColorRed
+ .extension<StackColors>()!
+ .accentColorRed
: Theme.of(context)
- .extension<StackColors>()!
- .accentColorGreen,
- ),
+ .extension<StackColors>()!
+ .accentColorGreen,
+ ),
),
],
),
@@ -563,10 +545,9 @@ class _DesktopCoinControlUseDialogState
child: SecondaryButton(
enabled: _selectedUTXOsData.isNotEmpty,
buttonHeight: ButtonHeight.l,
- label:
- _selectedUTXOsData.isEmpty
- ? "Clear selection"
- : "Clear selection (${_selectedUTXOsData.length})",
+ label: _selectedUTXOsData.isEmpty
+ ? "Clear selection"
+ : "Clear selection (${_selectedUTXOsData.length})",
onPressed: () {
setState(() {
_selectedUTXOsData.clear();
### lib/pages_desktop_specific/coin_control/desktop_coin_control_view.dart
@@ -17,6 +17,7 @@ import 'package:isar_community/isar.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/blockchain_data/utxo.dart';
+import '../../providers/global/locale_provider.dart';
import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
@@ -84,13 +85,18 @@ class _DesktopCoinControlViewState
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
+
if (_sort == CCSortDescriptor.address) {
_list = null;
_map = MainDB.instance.queryUTXOsGroupedByAddressSync(
walletId: widget.walletId,
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
} else {
@@ -100,6 +106,7 @@ class _DesktopCoinControlViewState
filter: _filter,
sort: _sort,
searchTerm: _searchString,
+ locale: locale,
cryptoCurrency: coin,
);
}
@@ -114,19 +121,17 @@ class _DesktopCoinControlViewState
const SizedBox(width: 32),
AppBarIconButton(
size: 32,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
shadows: const [],
icon: SvgPicture.asset(
Assets.svg.arrowLeft,
width: 18,
height: 18,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
),
onPressed: Navigator.of(context).pop,
),
@@ -135,8 +140,9 @@ class _DesktopCoinControlViewState
Assets.svg.coinControl.gamePad,
width: 32,
height: 32,
- color:
- Theme.of(context).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
const SizedBox(width: 12),
Text("Coin control", style: STextStyles.desktopH3(context)),
@@ -167,54 +173,52 @@ class _DesktopCoinControlViewState
_searchString = value;
});
},
- style: STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- ),
- decoration: standardInputDecoration(
- "Search...",
- searchFieldFocusNode,
- context,
- desktopMed: true,
- ).copyWith(
- prefixIcon: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 18,
+ style: STextStyles.desktopTextExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
+ height: 1.8,
),
- child: SvgPicture.asset(
- Assets.svg.search,
- width: 20,
- height: 20,
- ),
- ),
- suffixIcon:
- _searchController.text.isNotEmpty
+ decoration:
+ standardInputDecoration(
+ "Search...",
+ searchFieldFocusNode,
+ context,
+ desktopMed: true,
+ ).copyWith(
+ prefixIcon: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 18,
+ ),
+ child: SvgPicture.asset(
+ Assets.svg.search,
+ width: 20,
+ height: 20,
+ ),
+ ),
+ suffixIcon: _searchController.text.isNotEmpty
? Padding(
- padding: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- _searchController.text = "";
- _searchString = "";
- });
- },
- ),
- ],
+ padding: const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ _searchController.text = "";
+ _searchString = "";
+ });
+ },
+ ),
+ ],
+ ),
),
- ),
- )
+ )
: null,
- ),
+ ),
),
),
),
@@ -238,10 +242,9 @@ class _DesktopCoinControlViewState
key: Key("${_selectedUTXOs.length}"),
selectedUTXOs: _selectedUTXOs,
),
- crossFadeState:
- _selectedUTXOs.isEmpty
- ? CrossFadeState.showFirst
- : CrossFadeState.showSecond,
+ crossFadeState: _selectedUTXOs.isEmpty
+ ? CrossFadeState.showFirst
+ : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
const SizedBox(width: 24),
@@ -266,10 +269,9 @@ class _DesktopCoinControlViewState
label: "Clear selection (${_selectedUTXOs.length})",
onPressed: () => setState(() => _selectedUTXOs.clear()),
),
- crossFadeState:
- _selectedUTXOs.isEmpty
- ? CrossFadeState.showFirst
- : CrossFadeState.showSecond,
+ crossFadeState: _selectedUTXOs.isEmpty
+ ? CrossFadeState.showFirst
+ : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
@@ -278,140 +280,132 @@ class _DesktopCoinControlViewState
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
- child:
- _list != null
- ? ListView.separated(
- itemCount: _list!.length,
- separatorBuilder:
- (context, _) => const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(_list![index])
- .findFirstSync()!;
- final data = UtxoRowData(utxo.id, false);
- data.selected = _selectedUTXOs.contains(data);
+ child: _list != null
+ ? ListView.separated(
+ itemCount: _list!.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(_list![index])
+ .findFirstSync()!;
+ final data = UtxoRowData(utxo.id, false);
+ data.selected = _selectedUTXOs.contains(data);
- return UtxoRow(
- key: Key(
- "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
- ),
- data: data,
- walletId: widget.walletId,
- onSelectionChanged: (value) {
- setState(() {
- if (data.selected) {
- _selectedUTXOs.add(value);
- } else {
- _selectedUTXOs.remove(value);
- }
- });
- },
- );
- },
- )
- : ListView.separated(
- itemCount: _map!.entries.length,
- separatorBuilder:
- (context, _) => const SizedBox(height: 10),
- itemBuilder: (context, index) {
- final entry = _map!.entries.elementAt(index);
- final _controller = RotateIconController();
-
- return Expandable2(
- border:
- Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
- background:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- onExpandWillChange: (state) {
- if (state == Expandable2State.expanded) {
- _controller.forward?.call();
+ return UtxoRow(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
+ ),
+ data: data,
+ walletId: widget.walletId,
+ onSelectionChanged: (value) {
+ setState(() {
+ if (data.selected) {
+ _selectedUTXOs.add(value);
} else {
- _controller.reverse?.call();
+ _selectedUTXOs.remove(value);
}
- },
- header: RoundedContainer(
- padding: const EdgeInsets.all(20),
- color: Colors.transparent,
- child: Row(
- children: [
- SvgPicture.file(
- File(ref.watch(coinIconProvider(coin))),
- width: 24,
- height: 24,
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- entry.key,
- style: STextStyles.w600_14(context),
- ),
+ });
+ },
+ );
+ },
+ )
+ : ListView.separated(
+ itemCount: _map!.entries.length,
+ separatorBuilder: (context, _) =>
+ const SizedBox(height: 10),
+ itemBuilder: (context, index) {
+ final entry = _map!.entries.elementAt(index);
+ final _controller = RotateIconController();
+
+ return Expandable2(
+ border: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
+ background: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
+ animationDurationMultiplier: 0.2 * entry.value.length,
+ onExpandWillChange: (state) {
+ if (state == Expandable2State.expanded) {
+ _controller.forward?.call();
+ } else {
+ _controller.reverse?.call();
+ }
+ },
+ header: RoundedContainer(
+ padding: const EdgeInsets.all(20),
+ color: Colors.transparent,
+ child: Row(
+ children: [
+ SvgPicture.file(
+ File(ref.watch(coinIconProvider(coin))),
+ width: 24,
+ height: 24,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ entry.key,
+ style: STextStyles.w600_14(context),
),
- Expanded(
- child: Text(
- "${entry.value.length} "
- "output${entry.value.length > 1 ? "s" : ""}",
- style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- ),
+ ),
+ Expanded(
+ child: Text(
+ "${entry.value.length} "
+ "output${entry.value.length > 1 ? "s" : ""}",
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
- RotateIcon(
- animationDurationMultiplier:
- 0.2 * entry.value.length,
- icon: SvgPicture.asset(
- Assets.svg.chevronDown,
- width: 14,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
- ),
- curve: Curves.easeInOut,
- controller: _controller,
+ ),
+ RotateIcon(
+ animationDurationMultiplier:
+ 0.2 * entry.value.length,
+ icon: SvgPicture.asset(
+ Assets.svg.chevronDown,
+ width: 14,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
- ],
- ),
+ curve: Curves.easeInOut,
+ controller: _controller,
+ ),
+ ],
),
- children:
- entry.value.map((id) {
- final utxo =
- MainDB.instance.isar.utxos
- .where()
- .idEqualTo(id)
- .findFirstSync()!;
- final data = UtxoRowData(utxo.id, false);
- data.selected = _selectedUTXOs.contains(data);
+ ),
+ children: entry.value.map((id) {
+ final utxo = MainDB.instance.isar.utxos
+ .where()
+ .idEqualTo(id)
+ .findFirstSync()!;
+ final data = UtxoRowData(utxo.id, false);
+ data.selected = _selectedUTXOs.contains(data);
- return UtxoRow(
- key: Key(
- "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
- ),
- data: data,
- walletId: widget.walletId,
- raiseOnSelected: false,
- onSelectionChanged: (value) {
- setState(() {
- if (data.selected) {
- _selectedUTXOs.add(value);
- } else {
- _selectedUTXOs.remove(value);
- }
- });
- },
- );
- }).toList(),
- );
- },
- ),
+ return UtxoRow(
+ key: Key(
+ "${utxo.walletId}_${utxo.id}_${utxo.isBlocked}",
+ ),
+ data: data,
+ walletId: widget.walletId,
+ raiseOnSelected: false,
+ onSelectionChanged: (value) {
+ setState(() {
+ if (data.selected) {
+ _selectedUTXOs.add(value);
+ } else {
+ _selectedUTXOs.remove(value);
+ }
+ });
+ },
+ );
+ }).toList(),
+ );
+ },
+ ),
),
),
],
### lib/pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart
@@ -44,6 +44,11 @@ import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_text_field.dart';
import '../../widgets/textfield_icon_button.dart';
+Future<void> loadAndPresentDesktopTradeDetails<T>({
+ required Future<T> Function() load,
+ required void Function(T) present,
+}) async => present(await load());
+
class DesktopAllTradesView extends ConsumerStatefulWidget {
const DesktopAllTradesView({super.key});
@@ -107,19 +112,17 @@ class _DesktopAllTradesViewState extends ConsumerState<DesktopAllTradesView> {
const SizedBox(width: 32),
AppBarIconButton(
size: 32,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
shadows: const [],
icon: SvgPicture.asset(
Assets.svg.arrowLeft,
width: 18,
height: 18,
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.topNavIconPrimary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .topNavIconPrimary,
),
onPressed: Navigator.of(context).pop,
),
@@ -150,54 +153,52 @@ class _DesktopAllTradesViewState extends ConsumerState<DesktopAllTradesView> {
_searchString = value;
});
},
- style: STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- ),
- decoration: standardInputDecoration(
- "Search...",
- searchFieldFocusNode,
- context,
- desktopMed: true,
- ).copyWith(
- prefixIcon: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 18,
+ style: STextStyles.desktopTextExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
+ height: 1.8,
),
- child: SvgPicture.asset(
- Assets.svg.search,
- width: 20,
- height: 20,
- ),
- ),
- suffixIcon:
- _searchController.text.isNotEmpty
+ decoration:
+ standardInputDecoration(
+ "Search...",
+ searchFieldFocusNode,
+ context,
+ desktopMed: true,
+ ).copyWith(
+ prefixIcon: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 18,
+ ),
+ child: SvgPicture.asset(
+ Assets.svg.search,
+ width: 20,
+ height: 20,
+ ),
+ ),
+ suffixIcon: _searchController.text.isNotEmpty
? Padding(
- padding: const EdgeInsets.only(right: 0),
- child: UnconstrainedBox(
- child: Row(
- children: [
- TextFieldIconButton(
- child: const XIcon(),
- onTap: () async {
- setState(() {
- _searchController.text = "";
- _searchString = "";
- });
- },
- ),
- ],
+ padding: const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ children: [
+ TextFieldIconButton(
+ child: const XIcon(),
+ onTap: () async {
+ setState(() {
+ _searchController.text = "";
+ _searchString = "";
+ });
+ },
+ ),
+ ],
+ ),
),
- ),
- )
+ )
: null,
- ),
+ ),
),
),
),
@@ -240,25 +241,22 @@ class _DesktopAllTradesViewState extends ConsumerState<DesktopAllTradesView> {
child: ListView.separated(
shrinkWrap: true,
primary: false,
- separatorBuilder:
- (context, _) => Container(
- height: 1,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .background,
- ),
+ separatorBuilder: (context, _) => Container(
+ height: 1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
+ ),
itemCount: month.item2.length,
- itemBuilder:
- (context, index) => Padding(
- padding: const EdgeInsets.all(4),
- child: DesktopTradeRowCard(
- key: Key(
- "transactionCard_key_${month.item2[index].tradeId}",
- ),
- tradeId: month.item2[index].tradeId,
- ),
+ itemBuilder: (context, index) => Padding(
+ padding: const EdgeInsets.all(4),
+ child: DesktopTradeRowCard(
+ key: Key(
+ "transactionCard_key_${month.item2[index].tradeId}",
),
+ tradeId: month.item2[index].tradeId,
+ ),
+ ),
),
),
],
@@ -341,8 +339,9 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
.read(tradeSentFromStackLookupProvider)
.getWalletIdsForTradeId(tradeId);
- final trade =
- ref.watch(tradesServiceProvider.select((value) => value.get(tradeId)))!;
+ final trade = ref.watch(
+ tradesServiceProvider.select((value) => value.get(tradeId)),
+ )!;
return Material(
color: Theme.of(context).extension<StackColors>()!.popupBG,
@@ -363,36 +362,18 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
//todo: check if print needed
// debugPrint("name: ${manager.walletName}");
- final tx =
- await MainDB.instance
- .getTransactions(walletIds.first)
- .filter()
- .txidEqualTo(txid)
- .findFirst();
-
- if (mounted) {
- await showDialog<void>(
- context: context,
- builder:
- (context) => DesktopDialog(
- maxHeight: MediaQuery.of(context).size.height - 64,
- maxWidth: 580,
- child: TradeDetailsView(
- tradeId: tradeId,
- transactionIfSentFromStack: tx,
- walletName: ref.read(pWalletName(walletIds.first)),
- walletId: walletIds.first,
- ),
- ),
- );
- }
-
- if (mounted) {
- unawaited(
- showDialog<void>(
- context: context,
- builder:
- (context) => Navigator(
+ await loadAndPresentDesktopTradeDetails(
+ load: () => MainDB.instance
+ .getTransactions(walletIds.first)
+ .filter()
+ .txidEqualTo(txid)
+ .findFirst(),
+ present: (tx) {
+ if (mounted) {
+ unawaited(
+ showDialog<void>(
+ context: context,
+ builder: (context) => Navigator(
initialRoute: TradeDetailsView.routeName,
onGenerateRoute: RouteGenerator.generateRoute,
onGenerateInitialRoutes: (_, __) {
@@ -420,11 +401,10 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
),
),
DesktopDialogCloseButton(
- onPressedOverride:
- Navigator.of(
- context,
- rootNavigator: true,
- ).pop,
+ onPressedOverride: Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop,
),
],
),
@@ -452,70 +432,68 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
];
},
),
- ),
- );
- }
+ ),
+ );
+ }
+ },
+ );
} else {
unawaited(
showDialog<void>(
context: context,
- builder:
- (context) => Navigator(
- initialRoute: TradeDetailsView.routeName,
- onGenerateRoute: RouteGenerator.generateRoute,
- onGenerateInitialRoutes: (_, __) {
- return [
- FadePageRoute(
- DesktopDialog(
- maxHeight: null,
- maxWidth: 580,
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Padding(
- padding: const EdgeInsets.only(
- left: 32,
- bottom: 16,
- ),
- child: Row(
- mainAxisAlignment:
- MainAxisAlignment.spaceBetween,
- children: [
- Text(
- "Trade details",
- style: STextStyles.desktopH3(context),
- ),
- DesktopDialogCloseButton(
- onPressedOverride:
- Navigator.of(
- context,
- rootNavigator: true,
- ).pop,
- ),
- ],
+ builder: (context) => Navigator(
+ initialRoute: TradeDetailsView.routeName,
+ onGenerateRoute: RouteGenerator.generateRoute,
+ onGenerateInitialRoutes: (_, __) {
+ return [
+ FadePageRoute(
+ DesktopDialog(
+ maxHeight: null,
+ maxWidth: 580,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(
+ left: 32,
+ bottom: 16,
+ ),
+ child: Row(
+ mainAxisAlignment:
+ MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Trade details",
+ style: STextStyles.desktopH3(context),
),
- ),
- Flexible(
- child: SingleChildScrollView(
- primary: false,
- child: TradeDetailsView(
- tradeId: tradeId,
- transactionIfSentFromStack: null,
- walletName: null,
- walletId: walletIds?.first,
- ),
+ DesktopDialogCloseButton(
+ onPressedOverride: Navigator.of(
+ context,
+ rootNavigator: true,
+ ).pop,
),
+ ],
+ ),
+ ),
+ Flexible(
+ child: SingleChildScrollView(
+ primary: false,
+ child: TradeDetailsView(
+ tradeId: tradeId,
+ transactionIfSentFromStack: null,
+ walletName: null,
+ walletId: walletIds?.first,
),
- ],
+ ),
),
- ),
- const RouteSettings(
- name: TradeDetailsView.routeName,
- ),
+ ],
),
- ];
- },
- ),
+ ),
+ const RouteSettings(name: TradeDetailsView.routeName),
+ ),
+ ];
+ },
+ ),
),
);
}
@@ -547,12 +525,14 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
Expanded(
flex: 3,
child: Text(
- "${trade.payInCurrency.toUpperCase()} → ${trade.payOutCurrency.toUpperCase()}",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context).extension<StackColors>()!.textDark,
- ),
+ "${trade.payInCurrency.toUpperCase()} "
+ "→ ${trade.payOutCurrency.toUpperCase()}",
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
),
Expanded(
@@ -568,11 +548,12 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
flex: 6,
child: Text(
"-${Decimal.tryParse(trade.payInAmount)?.toStringAsFixed(8) ?? "..."} ${trade.payInCurrency.toUpperCase()}",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context).extension<StackColors>()!.textDark,
- ),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
),
Expanded(
@@ -586,8 +567,9 @@ class _DesktopTradeRowCardState extends ConsumerState<DesktopTradeRowCard> {
Assets.svg.circleInfo,
width: 20,
height: 20,
- color:
- Theme.of(context).extension<StackColors>()!.textSubtitle2,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle2,
),
],
),
### lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart
@@ -10,7 +10,6 @@
import 'dart:async';
-import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -81,9 +80,10 @@ class _StepScaffoldState extends ConsumerState<StepScaffold> {
builder: (_) => WillPopScope(
onWillPop: () async => false,
child: Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.overlay.withOpacity(0.6),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .overlay
+ .withOpacity(0.6),
child: const CustomLoadingOverlay(
message: "Creating a trade",
eventBus: null,
@@ -113,9 +113,10 @@ class _StepScaffoldState extends ConsumerState<StepScaffold> {
? ref.read(desktopExchangeModelProvider)!.receiveAmount
: ref.read(desktopExchangeModelProvider)!.sendAmount,
addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!,
- extraId: null,
+ extraId: ref.read(desktopExchangeModelProvider)!.extraId,
addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!,
- refundExtraId: "",
+ refundExtraId:
+ ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "",
estimate: ref.read(desktopExchangeModelProvider)!.estimate,
reversed: ref.read(desktopExchangeModelProvider)!.reversed,
);
@@ -210,14 +211,27 @@ class _StepScaffoldState extends ConsumerState<StepScaffold> {
}
void sendFromStack() {
- final trade = ref.read(desktopExchangeModelProvider)!.trade!;
+ final model = ref.read(desktopExchangeModelProvider)!;
+ final trade = model.trade!;
final address = trade.payInAddress;
final coin =
AppConfig.getCryptoCurrencyForTicker(trade.payInCurrency) ??
AppConfig.getCryptoCurrencyByPrettyName(trade.payInCurrency);
- final amount = Decimal.parse(
- trade.payInAmount,
- ).toAmount(fractionDigits: coin.fractionDigits);
+ final payInDecimal = model.payInDecimal;
+ if (payInDecimal == null) {
+ showDialog<void>(
+ context: context,
+ barrierDismissible: true,
+ builder: (_) => SimpleDesktopDialog(
+ title: "Invalid trade amount",
+ message:
+ "The exchange returned an invalid pay-in amount:"
+ " \"${trade.payInAmount}\"",
+ ),
+ );
+ return;
+ }
+ final amount = payInDecimal.toAmount(fractionDigits: coin.fractionDigits);
showDialog<void>(
context: context,
@@ -395,7 +409,7 @@ class _StepScaffoldState extends ConsumerState<StepScaffold> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
- "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address",
+ "Send ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))} to this address",
style: STextStyles.desktopH3(context),
),
const SizedBox(height: 48),
### lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart
@@ -17,8 +17,10 @@ import '../../../../app_config.dart';
import '../../../../models/contact_address_entry.dart';
import '../../../../providers/providers.dart';
import '../../../../themes/stack_colors.dart';
+import '../../../../utilities/address_utils.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
+import '../../../../utilities/extra_id_currency_support.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../widgets/custom_buttons/blue_text_button.dart';
@@ -53,9 +55,47 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
late final TextEditingController _toController;
late final TextEditingController _refundController;
+ late final TextEditingController _toMemoController;
+ late final TextEditingController _refundMemoController;
late final FocusNode _toFocusNode;
late final FocusNode _refundFocusNode;
+ late final FocusNode _toMemoFocusNode;
+ late final FocusNode _refundMemoFocusNode;
+
+ bool get _showRecipientMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(
+ ref.read(desktopExchangeModelProvider)!.receiveTicker,
+ );
+
+ bool get _showRefundMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(
+ ref.read(desktopExchangeModelProvider)!.sendTicker,
+ );
+
+ void _setRecipientMemo(String? memo) {
+ // A null memo means the selected address source did not provide one. In
+ // that case keep any memo the user already entered instead of wiping it.
+ if (memo == null) return;
+ final value = _showRecipientMemo ? memo : "";
+ _toMemoController.text = value;
+ ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty
+ ? null
+ : value;
+ }
+
+ void _setRefundMemo(String? memo) {
+ // A null memo means the selected address source did not provide one. In
+ // that case keep any memo the user already entered instead of wiping it.
+ if (memo == null) return;
+ final value = _showRefundMemo ? memo : "";
+ _refundMemoController.text = value;
+ ref.read(desktopExchangeModelProvider)!.refundExtraId = value.isEmpty
+ ? null
+ : value;
+ }
void selectRecipientAddressFromStack() async {
try {
@@ -211,9 +251,17 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
_toController = TextEditingController();
_refundController = TextEditingController();
+ _toMemoController = TextEditingController(
+ text: ref.read(desktopExchangeModelProvider)!.extraId ?? "",
+ );
+ _refundMemoController = TextEditingController(
+ text: ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "",
+ );
_toFocusNode = FocusNode();
_refundFocusNode = FocusNode();
+ _toMemoFocusNode = FocusNode();
+ _refundMemoFocusNode = FocusNode();
doesRefundAddress = ref.read(efExchangeProvider).supportsRefundAddress;
@@ -262,9 +310,13 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
void dispose() {
_toController.dispose();
_refundController.dispose();
+ _toMemoController.dispose();
+ _refundMemoController.dispose();
_toFocusNode.dispose();
_refundFocusNode.dispose();
+ _toMemoFocusNode.dispose();
+ _refundMemoFocusNode.dispose();
super.dispose();
}
@@ -292,9 +344,9 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
Text(
"Recipient Wallet",
style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
),
if (AppConfig.isStackCoin(
@@ -384,7 +436,18 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (data?.text != null &&
data!.text!.isNotEmpty) {
final content = data.text!.trim();
- _toController.text = content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging.instance,
+ );
+ if (paymentData != null) {
+ _toController.text =
+ paymentData.address;
+ _setRecipientMemo(paymentData.memo);
+ } else {
+ _toController.text = content;
+ }
ref
.read(desktopExchangeModelProvider)!
.recipientAddress = _toController
@@ -416,6 +479,42 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
),
),
),
+ if (_showRecipientMemo) const SizedBox(height: 10),
+ if (_showRecipientMemo)
+ Text(
+ "Memo or destination tag",
+ style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
+ ),
+ ),
+ if (_showRecipientMemo) const SizedBox(height: 10),
+ if (_showRecipientMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("recipientExchangeStep2ViewMemoFieldKey"),
+ controller: _toMemoController,
+ focusNode: _toMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter the memo or tag required by the payout address, if any",
+ _toMemoFocusNode,
+ context,
+ desktopMed: true,
+ ),
+ ),
+ ),
const SizedBox(height: 10),
RoundedWhiteContainer(
borderColor: Theme.of(context).extension<StackColors>()!.background,
@@ -432,9 +531,9 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
Text(
"Refund Wallet (required)",
style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
),
if (AppConfig.isStackCoin(
@@ -528,7 +627,18 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
data!.text!.isNotEmpty) {
final content = data.text!.trim();
- _refundController.text = content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging.instance,
+ );
+ if (paymentData != null) {
+ _refundController.text =
+ paymentData.address;
+ _setRefundMemo(paymentData.memo);
+ } else {
+ _refundController.text = content;
+ }
ref
.read(desktopExchangeModelProvider)!
.refundAddress = _refundController
@@ -561,6 +671,41 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
),
),
),
+ if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10),
+ if (doesRefundAddress && _showRefundMemo)
+ Text(
+ "Refund memo or destination tag",
+ style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
+ ),
+ ),
+ if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10),
+ if (doesRefundAddress && _showRefundMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("refundExchangeStep2ViewMemoFieldKey"),
+ controller: _refundMemoController,
+ focusNode: _refundMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ ref.read(desktopExchangeModelProvider)!.refundExtraId =
+ value.isEmpty ? null : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter the memo or tag required by the refund address, if any",
+ _refundMemoFocusNode,
+ context,
+ desktopMed: true,
+ ),
+ ),
+ ),
if (doesRefundAddress) const SizedBox(height: 10),
if (doesRefundAddress)
RoundedWhiteContainer(
### lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart
@@ -20,9 +20,7 @@ import '../step_scaffold.dart';
import 'desktop_step_item.dart';
class DesktopStep3 extends ConsumerStatefulWidget {
- const DesktopStep3({
- super.key,
- });
+ const DesktopStep3({super.key});
@override
ConsumerState<DesktopStep3> createState() => _DesktopStep3State();
@@ -37,9 +35,7 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
"Confirm exchange details",
style: STextStyles.desktopTextMedium(context),
),
- const SizedBox(
- height: 20,
- ),
+ const SizedBox(height: 20),
RoundedWhiteContainer(
borderColor: Theme.of(context).extension<StackColors>()!.background,
padding: const EdgeInsets.all(0),
@@ -72,16 +68,19 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
color: Theme.of(context).extension<StackColors>()!.background,
),
DesktopStepItem(
- label: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.rateType),
+ label:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.rateType,
+ ),
) ==
ExchangeRateType.estimated
? "Estimated rate"
: "Fixed rate",
value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.rateInfo),
+ desktopExchangeModelProvider.select(
+ (value) => value!.rateInfo,
+ ),
),
),
Container(
@@ -92,12 +91,37 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
vertical: true,
label:
"Recipient ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} address",
- value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.recipientAddress),
+ value:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.recipientAddress,
+ ),
) ??
"Error",
),
+ if (ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId?.isNotEmpty == true,
+ ),
+ ))
+ Container(
+ height: 1,
+ color: Theme.of(context).extension<StackColors>()!.background,
+ ),
+ if (ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId?.isNotEmpty == true,
+ ),
+ ))
+ DesktopStepItem(
+ vertical: true,
+ label: "Recipient memo or tag",
+ value: ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId!,
+ ),
+ ),
+ ),
if (ref.watch(efExchangeProvider).supportsRefundAddress)
Container(
height: 1,
@@ -108,12 +132,39 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
vertical: true,
label:
"Refund ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} address",
- value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.refundAddress),
+ value:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundAddress,
+ ),
) ??
"Error",
),
+ if (ref.watch(efExchangeProvider).supportsRefundAddress &&
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId?.isNotEmpty == true,
+ ),
+ ))
+ Container(
+ height: 1,
+ color: Theme.of(context).extension<StackColors>()!.background,
+ ),
+ if (ref.watch(efExchangeProvider).supportsRefundAddress &&
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId?.isNotEmpty == true,
+ ),
+ ))
+ DesktopStepItem(
+ vertical: true,
+ label: "Refund memo or tag",
+ value: ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId!,
+ ),
+ ),
+ ),
],
),
),
### lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_4.dart
@@ -109,23 +109,21 @@ class _DesktopStep4State extends ConsumerState<DesktopStep4> {
child: RichText(
text: TextSpan(
text:
- "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ",
+ "You must send at least ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}. ",
style: STextStyles.label700(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningForeground,
fontSize: 14,
),
children: [
TextSpan(
text:
- "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toString()))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.",
+ "If you send less than ${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker))}, your transaction may not be converted and it may not be refunded.",
style: STextStyles.label(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.warningForeground,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .warningForeground,
fontSize: 14,
),
),
@@ -186,7 +184,7 @@ class _DesktopStep4State extends ConsumerState<DesktopStep4> {
DesktopStepItem(
label: "Amount",
value:
- "${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendAmount.toStringAsFixed(8)))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}",
+ "${ref.watch(desktopExchangeModelProvider.select((value) => value!.payInAmount))} ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))}",
),
Container(
height: 1,
@@ -217,13 +215,12 @@ class _DesktopStep4State extends ConsumerState<DesktopStep4> {
),
Text(
_statusString,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .colorForStatus(_statusString),
- ),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .colorForStatus(_statusString),
+ ),
),
],
),
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -38,12 +38,15 @@ import '../../../../services/spark_names_service.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/assets.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
+import '../../../../utilities/enums/fee_rate_type_enum.dart';
+import '../../../../utilities/integer_input.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/prefs.dart';
import '../../../../utilities/show_loading.dart';
@@ -140,9 +143,13 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
bool get isPaynymSend => widget.accountLite != null;
- bool isCustomFee = false;
+ ({bool isValid, int? value}) get _nonceInput =>
+ parseOptionalIntegerInput(nonceController.text, minimum: 0);
+
+ bool get _nonceIsValid => _nonceInput.isValid;
+
int customFeeRate = 1;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
Future<void> scanWebcam() async {
try {
@@ -269,9 +276,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Text(
'Failed to create slatepack: $e',
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -394,9 +400,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Text(
'Failed to create slate: $e',
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -424,6 +429,10 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
Future<void> previewSend() async {
+ final nonceInput = _nonceInput;
+ if (!nonceInput.isValid) return;
+ final nonce = nonceInput.value;
+
final wallet = ref.read(pWallets).getWallet(walletId);
// Handle MWC slatepack transactions directly.
@@ -494,9 +503,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Text(
"You are about to send your entire balance. Would you like to continue?",
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -580,11 +588,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
TxData txData;
Future<TxData> txDataFuture;
+ final feeRateType = ref.read(feeRateTypeDesktopStateProvider);
+ final satsPerVByte = feeRateType.customSatsPerVByte(customFeeRate);
if (isPaynymSend) {
final paynymWallet = wallet as PaynymInterface;
-
- final feeRate = ref.read(feeRateTypeDesktopStateProvider);
txDataFuture = paynymWallet.preparePaymentCodeSend(
txData: TxData(
paynymAccountLite: widget.accountLite!,
@@ -596,8 +604,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
addressType: AddressType.unknown,
),
],
- satsPerVByte: isCustomFee ? customFeeRate : null,
- feeRateType: feeRate,
+ satsPerVByte: satsPerVByte,
+ feeRateType: feeRateType,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
@@ -621,8 +629,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
isChange: false,
),
],
- feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- satsPerVByte: isCustomFee ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
utxos:
(coinControlEnabled &&
ref.read(pDesktopUseUTXOs).isNotEmpty)
@@ -643,8 +651,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
)!,
),
],
- feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- satsPerVByte: isCustomFee ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
utxos:
(coinControlEnabled &&
ref.read(pDesktopUseUTXOs).isNotEmpty)
@@ -698,8 +706,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
addressType: wallet.cryptoCurrency.getAddressType(_address!)!,
),
],
- feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- satsPerVByte: isCustomFee ? customFeeRate : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
// these will need to be mweb utxos
// utxos:
// (wallet is CoinControlInterface &&
@@ -722,19 +730,17 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
],
memo: memo,
- feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- satsPerVByte: isCustomFee ? customFeeRate : null,
- nonce: wallet.cryptoCurrency is Ethereum
- ? int.tryParse(nonceController.text)
- : null,
+ feeRateType: feeRateType,
+ satsPerVByte: satsPerVByte,
+ nonce: wallet.cryptoCurrency is Ethereum ? nonce : null,
utxos:
(wallet is CoinControlInterface &&
wallet is! SalviumWallet &&
coinControlEnabled &&
ref.read(pDesktopUseUTXOs).isNotEmpty)
? ref.read(pDesktopUseUTXOs)
: null,
- ethEIP1559Fee: ethFee,
+ ethEIP1559Fee: _ethFee.value,
),
);
}
@@ -812,9 +818,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Text(
e.toString(),
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -857,10 +862,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
nonceController.text = "";
_address = "";
_addressToggleFlag = false;
+ _syncFeeAmount(null);
_setOpReturnData(null);
setState(() {});
}
+ void _syncFeeAmount(Amount? amount) {
+ ref.read(sendAmountProvider.notifier).state =
+ amount ?? Amount.zeroWith(fractionDigits: coin.fractionDigits);
+ }
+
void _setOpReturnData(String? data) {
if (!mounted) {
return;
@@ -872,7 +883,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(cryptoAmountController.text);
+ .tryParseEditable(cryptoAmountController.text);
final Amount? amount;
if (cryptoAmount != null) {
amount = cryptoAmount;
@@ -888,11 +899,13 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
?.value;
if (price != null && price > Decimal.zero) {
- final String fiatAmountString = (amount.decimal * price)
- .toAmount(fractionDigits: 2)
- .fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = (amount.decimal * price).toAmount(
+ fractionDigits: 2,
+ );
+ final fiatAmountString = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
baseAmountController.text = fiatAmountString;
}
@@ -999,13 +1012,23 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
// autofill amount field
if (paymentData.amount != null) {
- final amount = Decimal.parse(
+ final amount = Amount.tryParseCanonicalAmount(
paymentData.amount!,
- ).toAmount(fractionDigits: coin.fractionDigits);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
- ref.read(pSendAmount.notifier).state = amount;
+ fractionDigits: coin.fractionDigits,
+ truncateOverprecision: true,
+ );
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ ref.read(pSendAmount.notifier).state = amount;
+ _syncFeeAmount(amount);
+ } else {
+ cryptoAmountController.clear();
+ _cachedAmountToSend = null;
+ ref.read(pSendAmount.notifier).state = null;
+ _syncFeeAmount(null);
+ }
}
// Trigger validation after pasting.
@@ -1149,13 +1172,14 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
amount = Decimal.zero.toAmount(fractionDigits: coin.fractionDigits);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -1203,7 +1227,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
+ .formatEditable(amount);
+ _syncFeeAmount(amount);
}
void _showDesktopCoinControl() async {
@@ -1251,9 +1276,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
_cryptoAmountChangeLock = true;
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
- .format(
+ .formatEditable(
_data.amount!.toAmount(fractionDigits: coin.fractionDigits),
- withUnitName: false,
);
_cryptoAmountChangeLock = false;
}
@@ -1264,6 +1288,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (hasAmount) {
_cryptoAmountChanged();
+ _syncFeeAmount(ref.read(pSendAmount));
}
_setValidAddressProviders(_address);
});
@@ -1304,6 +1329,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
@override
void dispose() {
cryptoAmountController.removeListener(onCryptoAmountChanged);
+ _ethFee.dispose();
sendToController.dispose();
cryptoAmountController.dispose();
@@ -1325,6 +1351,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
final String locale = ref.watch(
localeServiceChangeNotifierProvider.select((value) => value.locale),
);
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref.watch(pPreviewTxButtonEnabled(coin));
+ final needsEthFee = coin is Ethereum && isCustomFee;
// add listener for epic cash to strip http:// and https:// prefixes if the address also ocntains an @ symbol (indicating an epicbox address)
if (coin is Epiccash) {
@@ -1400,23 +1436,22 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Container(
decoration: BoxDecoration(
color:
- Theme.of(
- context,
- ).extension<StackColors>()?.textFieldDefaultBG ??
+ Theme.of(context)
+ .extension<StackColors>()
+ ?.textFieldDefaultBG ??
Colors.white, // Fallback color
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
- Theme.of(
- context,
- ).extension<StackColors>()?.backgroundAppBar ??
+ Theme.of(context)
+ .extension<StackColors>()
+ ?.backgroundAppBar ??
Colors.grey, // Fallback color
width: 1,
),
),
child: const SizedBox(
- height:
- 60, // Provide an explicit height to avoid infinite constraints
+ height: 60, // Provide an explicit height to avoid infinite constraints
child: MwcTxsMethodToggle(),
),
),
@@ -1428,23 +1463,22 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Container(
decoration: BoxDecoration(
color:
- Theme.of(
- context,
- ).extension<StackColors>()?.textFieldDefaultBG ??
+ Theme.of(context)
+ .extension<StackColors>()
+ ?.textFieldDefaultBG ??
Colors.white, // Fallback color
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
- Theme.of(
- context,
- ).extension<StackColors>()?.backgroundAppBar ??
+ Theme.of(context)
+ .extension<StackColors>()
+ ?.backgroundAppBar ??
Colors.grey, // Fallback color
width: 1,
),
),
child: const SizedBox(
- height:
- 60, // Provide an explicit height to avoid infinite constraints
+ height: 60, // Provide an explicit height to avoid infinite constraints
child: EpicTxsMethodToggle(),
),
),
@@ -1454,9 +1488,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
Text(
"Send from",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -1537,9 +1571,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
offset: const Offset(0, -10),
elevation: 0,
decoration: BoxDecoration(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
@@ -1564,9 +1598,8 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
controller: sendToController,
enabled: false,
readOnly: true,
- style: STextStyles.desktopTextFieldLabel(
- context,
- ).copyWith(fontSize: 16),
+ style: STextStyles.desktopTextFieldLabel(context)
+ .copyWith(fontSize: 16),
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(
vertical: 18,
@@ -1581,9 +1614,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
Text(
"Amount",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -1616,6 +1649,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: coin.fractionDigits,
unit: ref.watch(pAmountUnit(coin)),
locale: locale,
@@ -1636,9 +1670,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -1647,9 +1681,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
child: Text(
ref.watch(pAmountUnit(coin)).unitForCoin(coin),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -1675,7 +1709,11 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
textAlign: TextAlign.right,
inputFormatters: [
- AmountInputFormatter(decimals: 2, locale: locale),
+ AmountInputFormatter(
+ controller: baseAmountController,
+ decimals: 2,
+ locale: locale,
+ ),
// // regex to validate a fiat amount with 2 decimal places
// TextInputFormatter.withFunction((oldValue, newValue) =>
// RegExp(r'^([0-9]*[,.]?[0-9]{0,2}|[,.][0-9]{0,2})$')
@@ -1692,9 +1730,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -1707,9 +1745,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -1720,9 +1758,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
if (showCoinControl)
RoundedContainer(
color: Colors.transparent,
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -1746,9 +1784,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
Text(
"Send to",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -1813,9 +1851,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
},
focusNode: _addressFocusNode,
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -1929,8 +1967,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
if (sendToController.text.isEmpty)
TextFieldIconButton(
- semanticsLabel:
- "Scan QR Button. Opens Camera For Scanning QR Code.",
+ semanticsLabel: "Scan QR Button. Opens Camera For Scanning QR Code.",
key: const Key("sendViewScanQrButtonKey"),
onTap: scanWebcam,
child: const QrCodeIcon(),
@@ -1986,9 +2023,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
error,
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
),
),
),
@@ -2017,9 +2054,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
"(${opData.length ~/ 2} bytes)",
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorGreen,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorGreen,
),
),
),
@@ -2047,9 +2084,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
"to complete the bridge transaction.",
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
),
),
),
@@ -2077,9 +2114,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
setState(() {});
},
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -2127,21 +2164,22 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
walletId: walletId,
isToken: false,
onCustomFeeSliderChanged: (value) => customFeeRate = value,
- onCustomFeeOptionChanged: (value) {
- isCustomFee = value;
+ onCustomFeeOptionChanged: () {
customFeeRate = 1;
- ethFee = null;
+ _ethFee.value = null;
+ },
+ onCustomEip1559FeeOptionChanged: (value) {
+ _ethFee.value = value;
},
- onCustomEip1559FeeOptionChanged: (value) => ethFee = value,
),
if (coin is Ethereum) const SizedBox(height: 20),
if (coin is Ethereum)
Text(
"Nonce",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -2159,12 +2197,13 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
readOnly: false,
autocorrect: false,
enableSuggestions: false,
- keyboardType: const TextInputType.numberWithOptions(),
+ keyboardType: TextInputType.number,
focusNode: _nonceFocusNode,
+ onChanged: (_) => setState(() {}),
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -2183,16 +2222,31 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
),
),
),
+ if (coin is Ethereum && !_nonceIsValid)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Enter a non-negative whole number",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
const SizedBox(height: 36),
- PrimaryButton(
- buttonHeight: ButtonHeight.l,
- label: ref.watch(pIsSlatepack(widget.walletId))
- ? "Create slatepack"
- : "Preview send",
- enabled: ref.watch(pPreviewTxButtonEnabled(coin)),
- onPressed: ref.watch(pPreviewTxButtonEnabled(coin))
- ? previewSend
- : null,
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ _nonceIsValid &&
+ (!needsEthFee || ethFee != null);
+ return PrimaryButton(
+ buttonHeight: ButtonHeight.l,
+ label: ref.watch(pIsSlatepack(widget.walletId))
+ ? "Create slatepack"
+ : "Preview send",
+ enabled: enabled,
+ onPressed: enabled ? previewSend : null,
+ );
+ },
),
],
);
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart';
import '../../../../providers/providers.dart';
+import '../../../../providers/ui/fee_rate_type_state_provider.dart';
import '../../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../../providers/wallet/desktop_fee_providers.dart';
import '../../../../providers/wallet/public_private_balance_state_provider.dart';
@@ -39,8 +40,8 @@ class DesktopSendFeeForm extends ConsumerStatefulWidget {
final String walletId;
final bool isToken;
final void Function(int) onCustomFeeSliderChanged;
- final void Function(bool) onCustomFeeOptionChanged;
- final void Function(EthEIP1559Fee)? onCustomEip1559FeeOptionChanged;
+ final VoidCallback onCustomFeeOptionChanged;
+ final void Function(EthEIP1559Fee?)? onCustomEip1559FeeOptionChanged;
@override
ConsumerState<DesktopSendFeeForm> createState() => _DesktopSendFeeFormState();
@@ -58,15 +59,6 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
bool get isEth => cryptoCurrency is Ethereum;
- bool _isCustomFeeValue = false;
- bool get _isCustomFee => _isCustomFeeValue;
- set _isCustomFee(bool newValue) {
- if (_isCustomFeeValue != newValue) {
- _isCustomFeeValue = newValue;
- widget.onCustomFeeOptionChanged.call(_isCustomFeeValue);
- }
- }
-
(FeeRateType, String?, String?)? feeSelectionResult;
Amount _addFiroOpReturnFee({
@@ -100,10 +92,24 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
void initState() {
super.initState();
cryptoCurrency = ref.read(pWalletCoin(widget.walletId));
+
+ // The fee rate type provider is global and never disposed. Reset it here
+ // so a stale custom selection from another wallet/send form can't cause a
+ // send using an unset custom fee rate.
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) {
+ ref.read(feeRateTypeDesktopStateProvider.state).state =
+ FeeRateType.average;
+ }
+ });
}
@override
Widget build(BuildContext context) {
+ final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
final canEditFees =
isEth ||
cryptoCurrency is Solana ||
@@ -124,6 +130,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
CustomTextButton(
text: "Edit",
onTap: () async {
+ final wasCustomFee = isCustomFee;
feeSelectionResult =
await showDialog<(FeeRateType, String?, String?)?>(
context: context,
@@ -134,12 +141,9 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
);
if (feeSelectionResult != null) {
- if (_isCustomFee &&
- feeSelectionResult!.$1 != FeeRateType.custom) {
- _isCustomFee = false;
- } else if (!_isCustomFee &&
- feeSelectionResult!.$1 == FeeRateType.custom) {
- _isCustomFee = true;
+ final selectedIsCustomFee = feeSelectionResult!.$1.isCustom;
+ if (wasCustomFee != selectedIsCustomFee) {
+ widget.onCustomFeeOptionChanged.call();
}
}
@@ -150,17 +154,17 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
),
child: Text(
"Transaction fee"
- "${_isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}",
+ "${isCustomFee ? "" : " (${isEth ? "max" : "estimated"})"}",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
),
const SizedBox(height: 10),
- if (!_isCustomFee)
+ if (!isCustomFee)
Padding(
padding: const EdgeInsets.all(10),
child: (feeSelectionResult?.$2 == null)
@@ -322,9 +326,9 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
feeSelectionResult?.$2 ?? "",
style: STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
),
textAlign: TextAlign.left,
),
@@ -340,15 +344,16 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
],
),
),
- if (_isCustomFee && isEth)
+ if (isCustomFee && isEth)
EthFeeForm(
+ locale: locale,
minGasLimit: widget.isToken
? kEthereumTokenMinGasLimit
: kEthereumMinGasLimit,
stateChanged: (value) =>
widget.onCustomEip1559FeeOptionChanged?.call(value),
),
- if (_isCustomFee && !isEth)
+ if (isCustomFee && !isEth)
Padding(
padding: const EdgeInsets.only(bottom: 12, top: 16),
child: FeeSlider(
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_sol_token_send.dart
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/isar/models/contact_entry.dart';
+import '../../../../models/isar/models/solana/sol_contract.dart';
import '../../../../models/paynym/paynym_account_lite.dart';
import '../../../../models/send_view_auto_fill_data.dart';
import '../../../../pages/send_view/confirm_transaction_view.dart';
@@ -25,8 +26,9 @@ import '../../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
-import '../../../../utilities/amount/amount_formatter.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
+import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
import '../../../../utilities/logger.dart';
@@ -50,6 +52,23 @@ import '../../../../widgets/textfield_icon_button.dart';
import '../../../desktop_home_view.dart';
import 'address_book_address_chooser/address_book_address_chooser.dart';
+Amount? parseDesktopSolTokenAmount(
+ String value, {
+ required String locale,
+ required CryptoCurrency coin,
+ required SolContract tokenContract,
+}) => AmountUnit.normal.tryParse(
+ value,
+ locale: locale,
+ coin: coin,
+ tokenContract: tokenContract,
+);
+
+Amount? parseDesktopSolTokenFiatAmount(
+ String value, {
+ required String locale,
+}) => Amount.tryParseFiatString(value, locale: locale);
+
class DesktopSolTokenSend extends ConsumerStatefulWidget {
const DesktopSolTokenSend({
super.key,
@@ -162,9 +181,8 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
"You are about to send your entire balance. "
"Would you like to continue?",
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -324,9 +342,8 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
child: SelectableText(
e.toString(),
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -372,52 +389,45 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
- // Get the token's decimal places for proper amount parsing
- final tokenDecimals = ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals;
+ // Get the token's decimal places for proper amount parsing. May still
+ // be null while the token wallet is loading (e.g. a locale change
+ // fires the relocalization listener before init completes).
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet);
+ if (tokenWallet == null) return;
if (cryptoAmountController.text.isNotEmpty &&
cryptoAmountController.text != "." &&
cryptoAmountController.text != ",") {
- try {
- // Parse the amount using the token's decimal places, not the coin's
- final inputDecimal = Decimal.parse(
- cryptoAmountController.text.replaceFirst(",", "."),
- );
- final cryptoAmount = Amount.fromDecimal(
- inputDecimal,
- fractionDigits: tokenDecimals,
- );
-
- // Only proceed if the parsed amount is valid
- if (cryptoAmount.raw > BigInt.zero) {
- _amountToSend = cryptoAmount;
- if (_cachedAmountToSend != null &&
- _cachedAmountToSend == _amountToSend) {
- return;
- }
- _cachedAmountToSend = _amountToSend;
-
- final price = ref
- .read(priceAnd24hChangeNotifierProvider)
- .getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
- ?.value;
-
- if (price != null && price > Decimal.zero) {
- final String fiatAmountString =
- Amount.fromDecimal(
- _amountToSend!.decimal * price,
- fractionDigits: 2,
- ).fiatString(
- locale: ref
- .read(localeServiceChangeNotifierProvider)
- .locale,
- );
-
- baseAmountController.text = fiatAmountString;
- }
+ final parsedAmount = parseDesktopSolTokenAmount(
+ cryptoAmountController.text,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ coin: coin,
+ tokenContract: tokenWallet.solContract,
+ );
+ if (parsedAmount != null) {
+ _amountToSend = parsedAmount;
+ if (_cachedAmountToSend != null &&
+ _cachedAmountToSend == _amountToSend) {
+ return;
+ }
+ _cachedAmountToSend = _amountToSend;
+
+ final price = ref
+ .read(priceAnd24hChangeNotifierProvider)
+ .getTokenPrice(tokenWallet.tokenMint)
+ ?.value;
+
+ if (price != null && price > Decimal.zero) {
+ final fiatAmount = Amount.fromDecimal(
+ _amountToSend!.decimal * price,
+ fractionDigits: 2,
+ );
+ baseAmountController.text = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
- } catch (e) {
- // Probably an invalid decimal input.
+ } else {
_amountToSend = null;
_cachedAmountToSend = null;
baseAmountController.text = "";
@@ -496,14 +506,22 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
+ final amount = Amount.tryParseCanonicalAmount(
+ paymentData.amount!,
fractionDigits: ref.read(pCurrentSolanaTokenWallet)!.tokenDecimals,
+ truncateOverprecision: true,
);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
-
- _amountToSend = amount;
+ if (amount != null) {
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ _amountToSend = amount;
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -555,15 +573,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
.read(pCurrentSolanaTokenWallet)!
.tokenDecimals;
- if (baseAmountString.isNotEmpty &&
- baseAmountString != "." &&
- baseAmountString != ",") {
- final baseAmount = baseAmountString.contains(",")
- ? Decimal.parse(
- baseAmountString.replaceFirst(",", "."),
- ).toAmount(fractionDigits: 2)
- : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
+ final baseAmount = parseDesktopSolTokenFiatAmount(
+ baseAmountString,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ if (baseAmount != null) {
final Decimal? _price = ref
.read(priceAnd24hChangeNotifierProvider)
.getTokenPrice(ref.read(pCurrentSolanaTokenWallet)!.tokenMint)
@@ -583,15 +598,15 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
}
_cachedAmountToSend = _amountToSend;
- final amountString = ref
- .read(pAmountFormatter(coin))
- .format(_amountToSend!, withUnitName: false);
-
_cryptoAmountChangeLock = true;
- cryptoAmountController.text = amountString;
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ _amountToSend!.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -609,8 +624,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
)),
);
- cryptoAmountController.text = balance.spendable.decimal.toStringAsFixed(
- tokenWallet.tokenDecimals,
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ balance.spendable.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
);
}
@@ -639,7 +655,15 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
if (_data != null) {
if (_data!.amount != null) {
- cryptoAmountController.text = _data!.amount!.toString();
+ final tokenWallet = ref.read(pCurrentSolanaTokenWallet)!;
+ final amount = Amount.fromDecimal(
+ _data!.amount!,
+ fractionDigits: tokenWallet.tokenDecimals,
+ );
+ cryptoAmountController.text = Amount.formatEditableDecimal(
+ amount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
}
sendToController.text = _data!.contactLabel;
_address = _data!.address;
@@ -670,6 +694,12 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+
final tokenWallet = ref.watch(pCurrentSolanaTokenWallet);
// If wallet is not initialized, show a placeholder.
@@ -690,9 +720,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Send from",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -702,9 +732,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Amount",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -733,8 +763,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenWallet.tokenDecimals,
- unit: ref.watch(pAmountUnit(coin)),
+ unit: AmountUnit.normal,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
@@ -760,9 +791,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -771,9 +802,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
child: Text(
tokenWallet.tokenSymbol,
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -805,6 +836,7 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
@@ -828,9 +860,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -843,9 +875,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
),
),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -856,9 +888,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
Text(
"Send to",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -895,9 +927,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
},
focusNode: _addressFocusNode,
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -1034,9 +1066,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
error,
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
),
),
),
@@ -1063,9 +1095,9 @@ class _DesktopSolTokenSendState extends ConsumerState<DesktopSolTokenSend> {
setState(() {});
},
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_token_send.dart
@@ -27,11 +27,14 @@ import '../../../../providers/wallet/desktop_fee_providers.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
+import '../../../../utilities/amount/amount_field_relocalization.dart';
import '../../../../utilities/amount/amount_formatter.dart';
import '../../../../utilities/amount/amount_input_formatter.dart';
import '../../../../utilities/amount/amount_unit.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
+import '../../../../utilities/enums/fee_rate_type_enum.dart';
+import '../../../../utilities/integer_input.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
@@ -55,6 +58,9 @@ import '../../../desktop_home_view.dart';
import 'address_book_address_chooser/address_book_address_chooser.dart';
import 'desktop_send_fee_form.dart';
+Amount? parseDesktopTokenFiatAmount(String value, {required String locale}) =>
+ Amount.tryParseFiatString(value, locale: locale);
+
class DesktopTokenSend extends ConsumerStatefulWidget {
const DesktopTokenSend({
super.key,
@@ -102,9 +108,18 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
bool _cryptoAmountChangeLock = false;
late VoidCallback onCryptoAmountChanged;
- EthEIP1559Fee? ethFee;
+ final _ethFee = ValueNotifier<EthEIP1559Fee?>(null);
+
+ ({bool isValid, int? value}) get _nonceInput =>
+ parseOptionalIntegerInput(nonceController.text, minimum: 0);
+
+ bool get _nonceIsValid => _nonceInput.isValid;
Future<void> previewSend() async {
+ final nonceInput = _nonceInput;
+ if (!nonceInput.isValid) return;
+ final nonce = nonceInput.value;
+
final tokenWallet = ref.read(pCurrentTokenWallet)!;
final Amount amount = _amountToSend!;
@@ -148,9 +163,8 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
child: Text(
"You are about to send your entire balance. Would you like to continue?",
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -242,8 +256,8 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
],
feeRateType: ref.read(feeRateTypeDesktopStateProvider),
- nonce: int.tryParse(nonceController.text),
- ethEIP1559Fee: ethFee,
+ nonce: nonce,
+ ethEIP1559Fee: _ethFee.value,
),
);
@@ -307,9 +321,8 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
child: SelectableText(
e.toString(),
textAlign: TextAlign.left,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(fontSize: 18),
+ style: STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(fontSize: 18),
),
),
const SizedBox(height: 40),
@@ -348,16 +361,23 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
nonceController.text = "";
_address = "";
_addressToggleFlag = false;
+ _syncFeeAmount(null);
if (mounted) {
setState(() {});
}
}
+ void _syncFeeAmount(Amount? amount) {
+ final tokenDecimals = ref.read(pCurrentTokenWallet)!.tokenContract.decimals;
+ ref.read(sendAmountProvider.notifier).state =
+ amount ?? Amount.zeroWith(fractionDigits: tokenDecimals);
+ }
+
void _cryptoAmountChanged() async {
if (!_cryptoAmountChangeLock) {
final cryptoAmount = ref
.read(pAmountFormatter(coin))
- .tryParse(
+ .tryParseEditable(
cryptoAmountController.text,
tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract,
);
@@ -376,13 +396,14 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
?.value;
if (price != null && price > Decimal.zero) {
- final String fiatAmountString =
- Amount.fromDecimal(
- _amountToSend!.decimal * price,
- fractionDigits: 2,
- ).fiatString(
- locale: ref.read(localeServiceChangeNotifierProvider).locale,
- );
+ final fiatAmount = Amount.fromDecimal(
+ _amountToSend!.decimal * price,
+ fractionDigits: 2,
+ );
+ final fiatAmountString = Amount.formatEditableDecimal(
+ fiatAmount.decimal,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
baseAmountController.text = fiatAmountString;
}
@@ -397,7 +418,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
}
String? _updateInvalidAddressText(String address) {
- if (_data != null && _data!.contactLabel == address) {
+ if (_data != null && _data.contactLabel == address) {
return null;
}
if (address.isNotEmpty &&
@@ -460,17 +481,26 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
// autofill amount field
if (paymentData.amount != null) {
- final Amount amount = Decimal.parse(paymentData.amount!).toAmount(
+ final amount = Amount.tryParseCanonicalAmount(
+ paymentData.amount!,
fractionDigits: ref
.read(pCurrentTokenWallet)!
.tokenContract
.decimals,
+ truncateOverprecision: true,
);
- cryptoAmountController.text = ref
- .read(pAmountFormatter(coin))
- .format(amount, withUnitName: false);
-
- _amountToSend = amount;
+ if (amount != null) {
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _amountToSend = amount;
+ _syncFeeAmount(amount);
+ } else {
+ cryptoAmountController.clear();
+ _amountToSend = null;
+ _cachedAmountToSend = null;
+ _syncFeeAmount(null);
+ }
}
_updatePreviewButtonState(_address, _amountToSend);
@@ -523,15 +553,12 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
.tokenContract
.decimals;
- if (baseAmountString.isNotEmpty &&
- baseAmountString != "." &&
- baseAmountString != ",") {
- final baseAmount = baseAmountString.contains(",")
- ? Decimal.parse(
- baseAmountString.replaceFirst(",", "."),
- ).toAmount(fractionDigits: 2)
- : Decimal.parse(baseAmountString).toAmount(fractionDigits: 2);
+ final baseAmount = parseDesktopTokenFiatAmount(
+ baseAmountString,
+ locale: ref.read(localeServiceChangeNotifierProvider).locale,
+ );
+ if (baseAmount != null) {
final Decimal? _price = ref
.read(priceAnd24hChangeNotifierProvider)
.getTokenPrice(ref.read(pCurrentTokenWallet)!.tokenContract.address)
@@ -553,17 +580,14 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
final amountString = ref
.read(pAmountFormatter(coin))
- .format(
- _amountToSend!,
- withUnitName: false,
- tokenContract: ref.read(pCurrentTokenWallet)!.tokenContract,
- );
+ .formatEditable(_amountToSend!);
_cryptoAmountChangeLock = true;
cryptoAmountController.text = amountString;
_cryptoAmountChangeLock = false;
} else {
_amountToSend = Decimal.zero.toAmount(fractionDigits: tokenDecimals);
+ _cachedAmountToSend = null;
_cryptoAmountChangeLock = true;
cryptoAmountController.text = "";
_cryptoAmountChangeLock = false;
@@ -573,19 +597,17 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
}
Future<void> sendAllTapped() async {
+ final tokenWallet = ref.read(pCurrentTokenWallet)!;
+ final balance = ref.read(
+ pTokenBalance((
+ walletId: walletId,
+ contractAddress: tokenWallet.tokenContract.address,
+ )),
+ );
cryptoAmountController.text = ref
- .read(
- pTokenBalance((
- walletId: walletId,
- contractAddress: ref
- .read(pCurrentTokenWallet)!
- .tokenContract
- .address,
- )),
- )
- .spendable
- .decimal
- .toStringAsFixed(ref.read(pCurrentTokenWallet)!.tokenContract.decimals);
+ .read(pAmountFormatter(coin))
+ .formatEditable(balance.spendable);
+ _syncFeeAmount(balance.spendable);
}
@override
@@ -611,11 +633,17 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
cryptoAmountController.addListener(onCryptoAmountChanged);
if (_data != null) {
- if (_data!.amount != null) {
- cryptoAmountController.text = _data!.amount!.toString();
+ if (_data.amount != null) {
+ final amount = _data.amount!.toAmount(
+ fractionDigits: ref.read(pCurrentTokenWallet)!.tokenContract.decimals,
+ );
+ cryptoAmountController.text = ref
+ .read(pAmountFormatter(coin))
+ .formatEditable(amount);
+ _syncFeeAmount(amount);
}
- sendToController.text = _data!.contactLabel;
- _address = _data!.address;
+ sendToController.text = _data.contactLabel;
+ _address = _data.address;
_addressToggleFlag = true;
}
@@ -649,6 +677,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
@override
void dispose() {
cryptoAmountController.removeListener(onCryptoAmountChanged);
+ _ethFee.dispose();
sendToController.dispose();
cryptoAmountController.dispose();
@@ -668,6 +697,18 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
debugPrint("BUILD: $runtimeType");
final tokenContract = ref.watch(pCurrentTokenWallet)!.tokenContract;
+ listenForAmountRelocalization(
+ ref.listen,
+ controllers: [cryptoAmountController, baseAmountController],
+ onRelocalized: _cryptoAmountChanged,
+ );
+ final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ // ethFee is checked in the ValueListenableBuilder around the preview
+ // button so fee keystrokes don't rebuild this whole view.
+ final previewEnabled = ref
+ .watch(previewTokenTxButtonStateProvider.state)
+ .state;
+ final needsEthFee = isCustomFee;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -677,9 +718,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
Text(
"Send from",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -689,9 +730,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
Text(
"Amount",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -720,6 +761,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: cryptoAmountController,
decimals: tokenContract.decimals,
unit: ref.watch(pAmountUnit(coin)),
locale: ref.watch(
@@ -747,9 +789,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -758,9 +800,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
child: Text(
ref.watch(pAmountUnit(coin)).unitForContract(tokenContract),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -792,6 +834,7 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
textAlign: TextAlign.right,
inputFormatters: [
AmountInputFormatter(
+ controller: baseAmountController,
decimals: 2,
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
@@ -815,9 +858,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
hintText: "0",
hintStyle: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultText,
),
prefixIcon: FittedBox(
fit: BoxFit.scaleDown,
@@ -830,9 +873,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
),
style: STextStyles.smallMed14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
),
),
),
@@ -843,9 +886,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
Text(
"Send to",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -882,9 +925,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
},
focusNode: _addressFocusNode,
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -1021,9 +1064,9 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
error,
textAlign: TextAlign.left,
style: STextStyles.label(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textError,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textError,
),
),
),
@@ -1036,18 +1079,20 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
walletId: walletId,
isToken: true,
onCustomFeeSliderChanged: (value) => {},
- onCustomFeeOptionChanged: (value) {
- ethFee = null;
+ onCustomFeeOptionChanged: () {
+ _ethFee.value = null;
+ },
+ onCustomEip1559FeeOptionChanged: (value) {
+ _ethFee.value = value;
},
- onCustomEip1559FeeOptionChanged: (value) => ethFee = value,
),
const SizedBox(height: 20),
Text(
"Nonce",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
),
textAlign: TextAlign.left,
),
@@ -1064,12 +1109,13 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
readOnly: false,
autocorrect: false,
enableSuggestions: false,
- keyboardType: const TextInputType.numberWithOptions(),
+ keyboardType: TextInputType.number,
focusNode: _nonceFocusNode,
+ onChanged: (_) => setState(() {}),
style: STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveText,
height: 1.8,
),
decoration:
@@ -1088,14 +1134,29 @@ class _DesktopTokenSendState extends ConsumerState<DesktopTokenSend> {
),
),
),
+ if (!_nonceIsValid)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Enter a non-negative whole number",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
const SizedBox(height: 36),
- PrimaryButton(
- buttonHeight: ButtonHeight.l,
- label: "Preview send",
- enabled: ref.watch(previewTokenTxButtonStateProvider.state).state,
- onPressed: ref.watch(previewTokenTxButtonStateProvider.state).state
- ? previewSend
- : null,
+ ValueListenableBuilder<EthEIP1559Fee?>(
+ valueListenable: _ethFee,
+ builder: (context, ethFee, _) {
+ final enabled =
+ previewEnabled &&
+ _nonceIsValid &&
+ (!needsEthFee || ethFee != null);
+ return PrimaryButton(
+ buttonHeight: ButtonHeight.l,
+ label: "Preview send",
+ enabled: enabled,
+ onPressed: enabled ? previewSend : null,
+ );
+ },
),
],
);
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_keys_desktop_popup.dart
@@ -88,90 +88,16 @@ class WalletKeysDesktopPopup extends ConsumerWidget {
const SizedBox(height: 6),
frostData != null
? Column(
- children: [
- Text("Keys", style: STextStyles.desktopTextMedium(context)),
- const SizedBox(height: 8),
- Center(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 32),
- child: RoundedWhiteContainer(
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 9,
- ),
- child: Row(
- children: [
- Flexible(
- child: SelectableText(
- frostData!.keys,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- textAlign: TextAlign.center,
- ),
- ),
- const SizedBox(width: 10),
- IconCopyButton(data: frostData!.keys),
- // TODO [prio=low: Add QR code button and dialog.
- ],
- ),
- ),
- ),
- ),
- const SizedBox(height: 24),
- Text("Config", style: STextStyles.desktopTextMedium(context)),
- const SizedBox(height: 8),
- Center(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 32),
- child: RoundedWhiteContainer(
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 9,
- ),
- child: Row(
- children: [
- Flexible(
- child: SelectableText(
- frostData!.config,
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ),
- textAlign: TextAlign.center,
- ),
- ),
- const SizedBox(width: 10),
- IconCopyButton(data: frostData!.config),
- // TODO [prio=low: Add QR code button and dialog.
- ],
- ),
- ),
- ),
- ),
- if (frostData?.prevGen != null) const SizedBox(height: 24),
- if (frostData?.prevGen != null)
- Text(
- "Previous generation Keys",
- style: STextStyles.desktopTextMedium(context),
- ),
- if (frostData?.prevGen != null) const SizedBox(height: 8),
- if (frostData?.prevGen != null)
+ children: [
+ Text("Keys", style: STextStyles.desktopTextMedium(context)),
+ const SizedBox(height: 8),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: RoundedWhiteContainer(
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
@@ -195,22 +121,19 @@ class WalletKeysDesktopPopup extends ConsumerWidget {
),
),
),
- if (frostData?.prevGen != null) const SizedBox(height: 24),
- if (frostData?.prevGen != null)
+ const SizedBox(height: 24),
Text(
- "Previous generation Config",
+ "Config",
style: STextStyles.desktopTextMedium(context),
),
- if (frostData?.prevGen != null) const SizedBox(height: 8),
- if (frostData?.prevGen != null)
+ const SizedBox(height: 8),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: RoundedWhiteContainer(
- borderColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 9,
@@ -219,56 +142,136 @@ class WalletKeysDesktopPopup extends ConsumerWidget {
children: [
Flexible(
child: SelectableText(
- frostData!.prevGen!.config,
+ frostData!.config,
style: STextStyles.desktopTextExtraExtraSmall(
context,
),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 10),
- IconCopyButton(data: frostData!.prevGen!.config),
+ IconCopyButton(data: frostData!.config),
// TODO [prio=low: Add QR code button and dialog.
],
),
),
),
),
- const SizedBox(height: 24),
- ],
- )
- : keyData != null
- ? keyData is ViewOnlyWalletData
- ? Padding(
- padding: const EdgeInsets.symmetric(horizontal: 16),
- child: ViewOnlyWalletDataWidget(
- data: keyData as ViewOnlyWalletData,
- ),
- )
- : CustomTabView(
- titles: [
- if (words.isNotEmpty) "Mnemonic",
- if (keyData is XPrivData) "XPriv(s)",
- if (keyData is CWKeyData) "Keys",
- ],
- children: [
- if (words.isNotEmpty)
- Padding(
- padding: const EdgeInsets.only(top: 16),
- child: _Mnemonic(words: words),
+ if (frostData?.prevGen != null) const SizedBox(height: 24),
+ if (frostData?.prevGen != null)
+ Text(
+ "Previous generation Keys",
+ style: STextStyles.desktopTextMedium(context),
+ ),
+ if (frostData?.prevGen != null) const SizedBox(height: 8),
+ if (frostData?.prevGen != null)
+ Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: RoundedWhiteContainer(
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 9,
+ ),
+ child: Row(
+ children: [
+ Flexible(
+ child: SelectableText(
+ frostData!.prevGen!.keys,
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(width: 10),
+ IconCopyButton(data: frostData!.prevGen!.keys),
+ // TODO [prio=low: Add QR code button and dialog.
+ ],
+ ),
+ ),
),
- if (keyData is XPrivData)
- WalletXPrivs(
- xprivData: keyData as XPrivData,
- walletId: walletId,
+ ),
+ if (frostData?.prevGen != null) const SizedBox(height: 24),
+ if (frostData?.prevGen != null)
+ Text(
+ "Previous generation Config",
+ style: STextStyles.desktopTextMedium(context),
+ ),
+ if (frostData?.prevGen != null) const SizedBox(height: 8),
+ if (frostData?.prevGen != null)
+ Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: RoundedWhiteContainer(
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 9,
+ ),
+ child: Row(
+ children: [
+ Flexible(
+ child: SelectableText(
+ frostData!.prevGen!.config,
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(width: 10),
+ IconCopyButton(
+ data: frostData!.prevGen!.config,
+ ),
+ // TODO [prio=low: Add QR code button and dialog.
+ ],
+ ),
+ ),
),
- if (keyData is CWKeyData)
- CNWalletKeys(
- cwKeyData: keyData as CWKeyData,
- walletId: walletId,
+ ),
+ const SizedBox(height: 24),
+ ],
+ )
+ : keyData != null
+ ? keyData is ViewOnlyWalletData
+ ? Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: ViewOnlyWalletDataWidget(
+ data: keyData as ViewOnlyWalletData,
),
- ],
- )
+ )
+ : CustomTabView(
+ titles: [
+ if (words.isNotEmpty) "Mnemonic",
+ if (keyData is XPrivData) "XPriv(s)",
+ if (keyData is CWKeyData) "Keys",
+ ],
+ children: [
+ if (words.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 16),
+ child: _Mnemonic(words: words),
+ ),
+ if (keyData is XPrivData)
+ WalletXPrivs(
+ xprivData: keyData as XPrivData,
+ walletId: walletId,
+ ),
+ if (keyData is CWKeyData)
+ CNWalletKeys(
+ cwKeyData: keyData as CWKeyData,
+ walletId: walletId,
+ ),
+ ],
+ )
: _Mnemonic(words: words),
const SizedBox(height: 32),
],
@@ -311,8 +314,9 @@ class _Mnemonic extends StatelessWidget {
child: MnemonicTable(
words: words,
isDesktop: true,
- itemBorderColor:
- Theme.of(context).extension<StackColors>()!.buttonBackSecondary,
+ itemBorderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonBackSecondary,
),
),
const SizedBox(height: 24),
### lib/pages_desktop_specific/settings/settings_menu/advanced_settings/advanced_settings.dart
@@ -43,9 +43,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
child: Column(
children: [
Padding(
- padding: const EdgeInsets.only(
- right: 30,
- ),
+ padding: const EdgeInsets.only(right: 30),
child: RoundedWhiteContainer(
radiusMultiplier: 2,
child: Column(
@@ -74,7 +72,8 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
TextSpan(
text:
- "\n\nConfigure these settings only if you know what you are doing!",
+ "\n\nConfigure these settings only if you"
+ " know what you are doing!",
style: STextStyles.desktopTextExtraExtraSmall(
context,
),
@@ -85,50 +84,50 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
- Padding(
- padding: const EdgeInsets.all(10),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- "Toggle testnet coins",
- style: STextStyles.desktopTextExtraSmall(context)
- .copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
+ if (!AppConfig.isSingleCoinApp)
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "Toggle testnet coins",
+ style:
+ STextStyles.desktopTextExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
+ textAlign: TextAlign.left,
),
- textAlign: TextAlign.left,
- ),
- SizedBox(
- height: 20,
- width: 40,
- child: DraggableSwitchButton(
- isOn: ref.watch(
- prefsChangeNotifierProvider.select(
- (value) => value.showTestNetCoins,
+ SizedBox(
+ height: 20,
+ width: 40,
+ child: DraggableSwitchButton(
+ isOn: ref.watch(
+ prefsChangeNotifierProvider.select(
+ (value) => value.showTestNetCoins,
+ ),
),
+ onValueChanged: (newValue) {
+ ref
+ .read(prefsChangeNotifierProvider)
+ .showTestNetCoins =
+ newValue;
+ },
),
- onValueChanged: (newValue) {
- ref
- .read(prefsChangeNotifierProvider)
- .showTestNetCoins = newValue;
- },
),
- ),
- ],
+ ],
+ ),
),
- ),
- const Padding(
- padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
+ if (!AppConfig.isSingleCoinApp)
+ const Padding(
+ padding: EdgeInsets.all(10.0),
+ child: Divider(thickness: 0.5),
),
- ),
Padding(
padding: const EdgeInsets.all(10),
child: Row(
@@ -138,10 +137,10 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
"Enable coin control",
style: STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
SizedBox(
@@ -155,8 +154,9 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .enableCoinControl = newValue;
+ .read(prefsChangeNotifierProvider)
+ .enableCoinControl =
+ newValue;
},
),
),
@@ -167,9 +167,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
if (Constants.enableExchange)
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
if (Constants.enableExchange)
Padding(
@@ -182,10 +180,10 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
style:
STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
SizedBox(
@@ -199,8 +197,9 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
onValueChanged: (newValue) {
ref
- .read(prefsChangeNotifierProvider)
- .enableExchange = newValue;
+ .read(prefsChangeNotifierProvider)
+ .enableExchange =
+ newValue;
},
),
),
@@ -209,17 +208,16 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
/// TODO: Make a dialog popup
Consumer(
builder: (_, ref, __) {
final externalCalls = ref.watch(
- prefsChangeNotifierProvider
- .select((value) => value.externalCalls),
+ prefsChangeNotifierProvider.select(
+ (value) => value.externalCalls,
+ ),
);
return Padding(
padding: const EdgeInsets.all(10),
@@ -231,23 +229,24 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
children: [
Text(
"${AppConfig.prefix} Experience",
- style: STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ style:
+ STextStyles.desktopTextExtraSmall(
+ context,
+ ).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
Text(
externalCalls
? "Easy crypto"
: "Incognito",
- style: STextStyles
- .desktopTextExtraExtraSmall(
- context,
- ),
+ style:
+ STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ),
),
],
),
@@ -275,9 +274,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
Padding(
padding: const EdgeInsets.all(10),
@@ -288,10 +285,10 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
"Block explorers",
style: STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
PrimaryButton(
@@ -314,9 +311,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
Padding(
padding: const EdgeInsets.all(10),
@@ -327,10 +322,10 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
"Units",
style: STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
PrimaryButton(
@@ -353,9 +348,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
),
const Padding(
padding: EdgeInsets.all(10.0),
- child: Divider(
- thickness: 0.5,
- ),
+ child: Divider(thickness: 0.5),
),
Padding(
padding: const EdgeInsets.all(10),
@@ -366,10 +359,10 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
"Logging",
style: STextStyles.desktopTextExtraSmall(context)
.copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textDark,
- ),
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
textAlign: TextAlign.left,
),
PrimaryButton(
@@ -390,9 +383,7 @@ class _AdvancedSettings extends ConsumerState<AdvancedSettings> {
],
),
),
- const SizedBox(
- height: 10,
- ),
+ const SizedBox(height: 10),
],
),
),
### lib/pages_desktop_specific/settings/settings_menu/desktop_about_view.dart
@@ -83,59 +83,51 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
TextSpan(
text: "Terms of service",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: 14),
- recognizer:
- TapGestureRecognizer()
- ..onTap = () {
- launchUrl(
- Uri.parse(
- "https://stackwallet.com/terms-of-service.html",
- ),
- mode:
- LaunchMode
- .externalApplication,
- );
- },
+ style: STextStyles.richLink(context)
+ .copyWith(fontSize: 14),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () {
+ launchUrl(
+ Uri.parse(
+ "https://stackwallet.com/terms-of-service.html",
+ ),
+ mode:
+ LaunchMode.externalApplication,
+ );
+ },
),
TextSpan(
text: " and ",
style:
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
TextSpan(
text: "Privacy policy",
- style: STextStyles.richLink(
- context,
- ).copyWith(fontSize: 14),
- recognizer:
- TapGestureRecognizer()
- ..onTap = () {
- launchUrl(
- Uri.parse(
- "https://stackwallet.com/privacy-policy.html",
- ),
- mode:
- LaunchMode
- .externalApplication,
- );
- },
+ style: STextStyles.richLink(context)
+ .copyWith(fontSize: 14),
+ recognizer: TapGestureRecognizer()
+ ..onTap = () {
+ launchUrl(
+ Uri.parse(
+ "https://stackwallet.com/privacy-policy.html",
+ ),
+ mode:
+ LaunchMode.externalApplication,
+ );
+ },
),
],
),
@@ -154,10 +146,7 @@ class DesktopAboutView extends ConsumerWidget {
children: [
FutureBuilder(
future: PackageInfo.fromPlatform(),
- builder: (
- context,
- AsyncSnapshot<PackageInfo> snapshot,
- ) {
+ builder: (context, AsyncSnapshot<PackageInfo> snapshot) {
String version = "";
String signature = "";
String build = "";
@@ -190,12 +179,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
const SizedBox(height: 2),
@@ -220,12 +208,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
const SizedBox(height: 2),
@@ -250,12 +237,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
const SizedBox(height: 2),
@@ -284,12 +270,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
const SizedBox(height: 2),
@@ -304,185 +289,6 @@ class DesktopAboutView extends ConsumerWidget {
),
],
),
- const SizedBox(height: 32),
- Wrap(
- spacing: 64,
- runSpacing: 32,
- children: [
- if (AppConfig.coins
- .whereType<Epiccash>()
- .isNotEmpty)
- FutureBuilder(
- future:
- GitStatus.getEpicCommitStatus(),
- builder: (
- context,
- AsyncSnapshot<CommitStatus>
- snapshot,
- ) {
- CommitStatus stateOfCommit =
- CommitStatus.notLoaded;
-
- if (snapshot.connectionState ==
- ConnectionState
- .done &&
- snapshot.hasData) {
- stateOfCommit =
- snapshot.data!;
- }
-
- return Column(
- mainAxisSize:
- MainAxisSize.min,
- crossAxisAlignment:
- CrossAxisAlignment
- .start,
- children: [
- Text(
- "Epic Cash Build Commit",
- style: STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
- ),
- ),
- const SizedBox(height: 2),
- SelectableText(
- GitStatus
- .epicCashCommit,
- style:
- GitStatus.styleForStatus(
- stateOfCommit,
- context,
- ),
- ),
- ],
- );
- },
- ),
- if (AppConfig.coins
- .whereType<Mimblewimblecoin>()
- .isNotEmpty)
- FutureBuilder(
- future: GitStatus
- .getMimblewimblecoinCommitStatus(),
- builder: (
- context,
- AsyncSnapshot<CommitStatus>
- snapshot,
- ) {
- CommitStatus stateOfCommit =
- CommitStatus.notLoaded;
-
- if (snapshot.connectionState ==
- ConnectionState
- .done &&
- snapshot.hasData) {
- stateOfCommit =
- snapshot.data!;
- }
-
- return Column(
- mainAxisSize:
- MainAxisSize.min,
- crossAxisAlignment:
- CrossAxisAlignment
- .start,
- children: [
- Text(
- "Mimblewimblecoin Build Commit",
- style: STextStyles
- .desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- )
- .extension<
- StackColors>()!
- .textDark,
- ),
- ),
- const SizedBox(
- height: 2,
- ),
- SelectableText(
- GitStatus
- .mimblewimblecoinCommit,
- style: GitStatus
- .styleForStatus(
- stateOfCommit,
- context,
- ),
- ),
- ],
- );
- },
- ),
- //if (AppConfig.coins
- // .whereType<Monero>()
- // .isNotEmpty)
- // FutureBuilder(
- // future: GitStatus
- // .getMoneroCommitStatus(),
- // builder: (
- // context,
- // AsyncSnapshot<CommitStatus>
- // snapshot,
- // ) {
- // CommitStatus stateOfCommit =
- // CommitStatus.notLoaded;
- //
- // if (snapshot.connectionState ==
- // ConnectionState
- // .done &&
- // snapshot.hasData) {
- // stateOfCommit =
- // snapshot.data!;
- // }
- // return Column(
- // mainAxisSize:
- // MainAxisSize.min,
- // crossAxisAlignment:
- // CrossAxisAlignment
- // .start,
- // children: [
- // Text(
- // "Monero Build Commit",
- // style: STextStyles
- // .desktopTextExtraExtraSmall(
- // context,
- // ).copyWith(
- // color: Theme.of(
- // context,
- // )
- // .extension<
- // StackColors>()!
- // .textDark,
- // ),
- // ),
- // const SizedBox(
- // height: 2,
- // ),
- // SelectableText(
- // GitStatus.moneroCommit,
- // style: GitStatus
- // .styleForStatus(
- // stateOfCommit,
- // context,
- // ),
- // ),
- // ],
- // );
- // },
- // ),
- ],
- ),
const SizedBox(height: 35),
Row(
children: [
@@ -492,12 +298,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
CustomTextButton(
@@ -507,9 +312,8 @@ class DesktopAboutView extends ConsumerWidget {
Uri.parse(
"https://stackwallet.com",
),
- mode:
- LaunchMode
- .externalApplication,
+ mode: LaunchMode
+ .externalApplication,
);
},
),
@@ -530,12 +334,11 @@ class DesktopAboutView extends ConsumerWidget {
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color:
- Theme.of(context)
- .extension<
- StackColors
- >()!
- .textDark,
+ color: Theme.of(context)
+ .extension<
+ StackColors
+ >()!
+ .textDark,
),
),
CustomTextButton(
@@ -545,9 +348,8 @@ class DesktopAboutView extends ConsumerWidget {
Uri.parse(
"https://tzkt.io",
),
- mode:
- LaunchMode
- .externalApplication,
+ mode: LaunchMode
+ .externalApplication,
);
},
),
### lib/providers/exchange/exchange_form_state_provider.dart
@@ -10,30 +10,33 @@
import 'package:decimal/decimal.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:tuple/tuple.dart';
+
import '../../models/exchange/active_pair.dart';
import '../../models/exchange/response_objects/estimate.dart';
import '../../models/exchange/response_objects/range.dart';
-import '../global/locale_provider.dart';
import '../../services/exchange/exchange.dart';
import '../../services/exchange/exchange_response.dart';
import '../../utilities/amount/amount.dart';
-import '../../utilities/amount/amount_unit.dart';
-
import '../../utilities/enums/exchange_rate_type_enum.dart';
-import '../../wallets/crypto_currency/crypto_currency.dart';
-import 'package:tuple/tuple.dart';
+import '../global/locale_provider.dart';
-final efEstimatesListProvider = StateProvider.family<
- Tuple2<ExchangeResponse<List<Estimate>>, Range?>?,
- String>((ref, exchangeName) => null);
+final efEstimatesListProvider =
+ StateProvider.family<
+ Tuple2<ExchangeResponse<List<Estimate>>, Range?>?,
+ String
+ >((ref, exchangeName) => null);
-final efRateTypeProvider =
- StateProvider<ExchangeRateType>((ref) => ExchangeRateType.estimated);
+final efRateTypeProvider = StateProvider<ExchangeRateType>(
+ (ref) => ExchangeRateType.estimated,
+);
-final efExchangeProvider =
- StateProvider<Exchange>((ref) => Exchange.defaultExchange);
-final efExchangeProviderNameProvider =
- StateProvider<String>((ref) => Exchange.defaultExchange.name);
+final efExchangeProvider = StateProvider<Exchange>(
+ (ref) => Exchange.defaultExchange,
+);
+final efExchangeProviderNameProvider = StateProvider<String>(
+ (ref) => Exchange.defaultExchange.name,
+);
final currentCombinedExchangeIdProvider = Provider<String>((ref) {
return "${ref.watch(efExchangeProvider).name}"
@@ -52,17 +55,8 @@ final efSendAmountStringProvider = StateProvider<String>((ref) {
final decimal = ref.watch(efSendAmountProvider);
String string = "";
if (decimal != null) {
- final amount = Amount.fromDecimal(decimal, fractionDigits: decimal.scale);
final locale = ref.watch(localeServiceChangeNotifierProvider).locale;
- string = AmountUnit.normal.displayAmount(
- amount: amount,
- locale: locale,
- coin: Nano(
- CryptoCurrencyNetwork.main,
- ), // use nano just to ensure decimal.scale < Coin.value.decimals
- withUnitName: false,
- maxDecimalPlaces: decimal.scale,
- );
+ string = Amount.formatEditableDecimal(decimal, locale: locale);
}
return string;
@@ -78,17 +72,8 @@ final efReceiveAmountStringProvider = StateProvider<String>((ref) {
final decimal = ref.watch(efReceiveAmountProvider);
String string = "";
if (decimal != null) {
- final amount = Amount.fromDecimal(decimal, fractionDigits: decimal.scale);
final locale = ref.watch(localeServiceChangeNotifierProvider).locale;
- string = AmountUnit.normal.displayAmount(
- amount: amount,
- locale: locale,
- coin: Nano(
- CryptoCurrencyNetwork.main,
- ), // use nano just to ensure decimal.scale < Coin.value.decimals
- withUnitName: false,
- maxDecimalPlaces: decimal.scale,
- );
+ string = Amount.formatEditableDecimal(decimal, locale: locale);
}
return string;
@@ -112,10 +97,10 @@ final efEstimateProvider = StateProvider<Estimate?>((ref) {
?.item1
.value
?.where((e) {
- return e.exchangeProvider == provider &&
- e.fixedRate == fixedRate &&
- e.reversed == reversed;
- });
+ return e.exchangeProvider == provider &&
+ e.fixedRate == fixedRate &&
+ e.reversed == reversed;
+ });
Estimate? result;
### lib/providers/global/secure_store_provider.dart
@@ -24,7 +24,9 @@ final secureStoreProvider = Provider<SecureStorageInterface>((ref) {
);
} else {
return const SecureStorageWrapper(
- store: FlutterSecureStorage(),
+ store: FlutterSecureStorage(
+ aOptions: AndroidOptions(resetOnError: false, migrateWithBackup: true),
+ ),
isDesktop: false,
);
}
### lib/services/exchange/change_now/change_now_exchange.dart
@@ -52,6 +52,7 @@ class ChangeNowExchange extends Exchange {
toCurrency: to,
toNetwork: toNetwork ?? "",
address: addressTo,
+ extraId: extraId,
rateId: estimate?.rateId,
refundAddress: addressRefund,
refundExtraId: refundExtraId,
### lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
@@ -159,6 +159,9 @@ class CypherGoatExchange extends Exchange {
@override
bool get supportsRefundAddress => false;
+ @override
+ bool get supportsExtraId => false;
+
@override
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
bool fixedRate,
@@ -380,6 +383,12 @@ class CypherGoatExchange extends Exchange {
ExchangeExceptionType.generic,
);
}
+ if (extraId?.isNotEmpty == true || refundExtraId.isNotEmpty) {
+ throw ExchangeException(
+ "CypherGoat does not support destination or refund memos",
+ ExchangeExceptionType.generic,
+ );
+ }
final response = await CypherGoatAPI.createSwap(
coin1: from,
### lib/services/exchange/exchange.dart
@@ -61,6 +61,9 @@ abstract class Exchange {
bool get supportsRefundAddress => true;
+ /// Whether createTrade forwards a payout memo/destination tag to the API.
+ bool get supportsExtraId => true;
+
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(bool fixedRate);
// Future<ExchangeResponse<List<Currency>>> getPairedCurrencies(
### lib/services/exchange/nanswap/api_response_models/n_trade.dart
@@ -17,6 +17,9 @@ class NTrade {
final String? fromNetwork;
final String? toNetwork;
+ String get payInNetwork => fromNetwork ?? from;
+ String get payOutNetwork => toNetwork ?? to;
+
NTrade({
required this.id,
required this.from,
### lib/services/exchange/nanswap/nanswap_exchange.dart
@@ -1,4 +1,5 @@
import 'package:decimal/decimal.dart';
+import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../../../app_config.dart';
@@ -11,14 +12,26 @@ import '../../../models/isar/exchange_cache/pair.dart';
import '../exchange.dart';
import '../exchange_response.dart';
import 'api_response_models/n_estimate.dart';
+import 'api_response_models/n_trade.dart';
import 'nanswap_api.dart';
+typedef NanswapOrderLookup = Future<ExchangeResponse<NTrade>> Function({
+ required String id,
+});
+
class NanswapExchange extends Exchange {
- NanswapExchange._();
+ NanswapExchange._({NanswapOrderLookup? getOrder})
+ : _getOrder = getOrder ?? NanswapAPI.instance.getOrder;
+
+ @visibleForTesting
+ NanswapExchange.forTesting({required NanswapOrderLookup getOrder})
+ : this._(getOrder: getOrder);
static NanswapExchange? _instance;
static NanswapExchange get instance => _instance ??= NanswapExchange._();
+ final NanswapOrderLookup _getOrder;
+
static const exchangeName = "Nanswap";
static const filter = ["BTC", "BAN", "XNO"];
@@ -92,13 +105,13 @@ class NanswapExchange extends Exchange {
payInCurrency: from,
payInAmount: t.expectedAmountFrom.toString(),
payInAddress: t.payinAddress,
- payInNetwork: t.toNetwork ?? t.to,
+ payInNetwork: t.payInNetwork,
payInExtraId: t.payinExtraId ?? "",
payInTxid: t.payinHash ?? "",
payOutCurrency: to,
payOutAmount: t.expectedAmountTo.toString(),
payOutAddress: t.payoutAddress,
- payOutNetwork: t.fromNetwork ?? t.from,
+ payOutNetwork: t.payOutNetwork,
payOutExtraId: "",
payOutTxid: t.payoutHash ?? "",
refundAddress: "",
@@ -319,7 +332,7 @@ class NanswapExchange extends Exchange {
@override
Future<ExchangeResponse<Trade>> getTrade(String tradeId) async {
try {
- final response = await NanswapAPI.instance.getOrder(id: tradeId);
+ final response = await _getOrder(id: tradeId);
if (response.exception != null) {
return ExchangeResponse(exception: response.exception);
@@ -338,13 +351,13 @@ class NanswapExchange extends Exchange {
payInCurrency: t.from,
payInAmount: t.expectedAmountFrom.toString(),
payInAddress: t.payinAddress,
- payInNetwork: t.toNetwork ?? t.to,
+ payInNetwork: t.payInNetwork,
payInExtraId: t.payinExtraId ?? "",
payInTxid: t.payinHash ?? "",
payOutCurrency: t.to,
payOutAmount: t.expectedAmountTo.toString(),
payOutAddress: t.payoutAddress,
- payOutNetwork: t.fromNetwork ?? t.from,
+ payOutNetwork: t.payOutNetwork,
payOutExtraId: "",
payOutTxid: t.payoutHash ?? "",
refundAddress: "",
@@ -377,7 +390,7 @@ class NanswapExchange extends Exchange {
@override
Future<ExchangeResponse<Trade>> updateTrade(Trade trade) async {
try {
- final response = await NanswapAPI.instance.getOrder(id: trade.tradeId);
+ final response = await _getOrder(id: trade.tradeId);
if (response.exception != null) {
return ExchangeResponse(exception: response.exception);
@@ -396,13 +409,13 @@ class NanswapExchange extends Exchange {
payInCurrency: t.from,
payInAmount: t.expectedAmountFrom.toString(),
payInAddress: t.payinAddress,
- payInNetwork: t.toNetwork ?? trade.payInNetwork,
+ payInNetwork: t.payInNetwork,
payInExtraId: t.payinExtraId ?? trade.payInExtraId,
payInTxid: t.payinHash ?? trade.payInTxid,
payOutCurrency: t.to,
payOutAmount: t.expectedAmountTo.toString(),
payOutAddress: t.payoutAddress,
- payOutNetwork: t.fromNetwork ?? trade.payOutNetwork,
+ payOutNetwork: t.payOutNetwork,
payOutExtraId: trade.payOutExtraId,
payOutTxid: t.payoutHash ?? trade.payOutTxid,
refundAddress: trade.refundAddress,
### lib/services/exchange/trocador/trocador_exchange.dart
@@ -77,9 +77,9 @@ class TrocadorExchange extends Exchange {
toNetwork: onlySupportedNetwork,
toAmount: amount.toString(),
receivingAddress: addressTo,
- receivingMemo: null,
+ receivingMemo: extraId?.isNotEmpty == true ? extraId : null,
refundAddress: addressRefund,
- refundMemo: null,
+ refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null,
exchangeProvider: estimate!.exchangeProvider!,
isFixedRate: fixedRate,
)
@@ -92,9 +92,9 @@ class TrocadorExchange extends Exchange {
toNetwork: onlySupportedNetwork,
fromAmount: amount.toString(),
receivingAddress: addressTo,
- receivingMemo: null,
+ receivingMemo: extraId?.isNotEmpty == true ? extraId : null,
refundAddress: addressRefund,
- refundMemo: null,
+ refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null,
exchangeProvider: estimate!.exchangeProvider!,
isFixedRate: fixedRate,
);
### lib/services/notifications_api.dart
@@ -12,6 +12,7 @@ import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
+import '../app_config.dart';
import '../models/notification_model.dart';
import '../utilities/logger.dart';
import '../utilities/prefs.dart';
@@ -54,14 +55,31 @@ abstract final class NotificationApi {
defaultActionName: "temporary_stack_wallet",
);
const macOS = DarwinInitializationSettings();
- const settings = InitializationSettings(
+ final (windowsAppUserModelId, windowsGuid) = switch (AppConfig.appName) {
+ "Campfire" => (
+ "CypherStack.Campfire",
+ "fe1bb964-a80c-45cd-a5d6-c95b2d8b1142",
+ ),
+ "Stack Duo" => (
+ "CypherStack.StackDuo",
+ "7b01c69c-c282-493e-98a1-6a5cd3153049",
+ ),
+ _ => ("CypherStack.StackWallet", "986f4b88-0a22-42c2-a005-e5e150c8653f"),
+ };
+ final windows = WindowsInitializationSettings(
+ appName: AppConfig.appName,
+ appUserModelId: windowsAppUserModelId,
+ guid: windowsGuid,
+ );
+ final settings = InitializationSettings(
android: android,
iOS: iOS,
linux: linux,
macOS: macOS,
+ windows: windows,
);
await _notifications.initialize(
- settings,
+ settings: settings,
// onDidReceiveNotificationResponse: (payload) async {
// onNotifications.add(payload.payload);
// },
@@ -79,7 +97,7 @@ abstract final class NotificationApi {
static Future<void> clearNotification(int id) async {
await init();
- await _notifications.cancel(id);
+ await _notifications.cancel(id: id);
}
//===================================
@@ -94,10 +112,10 @@ abstract final class NotificationApi {
await init();
final id = await prefs.incrementCurrentNotificationIndex();
await _notifications.show(
- id,
- title,
- body,
- await _notificationDetails(),
+ id: id,
+ title: title,
+ body: body,
+ notificationDetails: await _notificationDetails(),
payload: payload,
);
return id;
### lib/services/notifications_service.dart
@@ -109,7 +109,7 @@ class NotificationsService extends ChangeNotifier {
_timer = Timer.periodic(notificationRefreshInterval, (_) {
Logging.instance.d("Periodic notifications update check");
if (prefs.externalCalls) {
- _checkTrades();
+ unawaited(_checkTrades());
}
_checkTransactions();
});
@@ -159,21 +159,20 @@ class NotificationsService extends ChangeNotifier {
torEnabled: node.torEnabled,
clearnetEnabled: node.clearnetEnabled,
);
- final failovers =
- nodeService
- .failoverNodesFor(currency: coin)
- .map(
- (e) => ElectrumXNode(
- address: e.host,
- port: e.port,
- name: e.name,
- id: e.id,
- useSSL: e.useSSL,
- torEnabled: node.torEnabled,
- clearnetEnabled: node.clearnetEnabled,
- ),
- )
- .toList();
+ final failovers = nodeService
+ .failoverNodesFor(currency: coin)
+ .map(
+ (e) => ElectrumXNode(
+ address: e.host,
+ port: e.port,
+ name: e.name,
+ id: e.id,
+ useSSL: e.useSSL,
+ torEnabled: node.torEnabled,
+ clearnetEnabled: node.clearnetEnabled,
+ ),
+ )
+ .toList();
final client = ElectrumXClient.from(
node: eNode,
@@ -233,7 +232,7 @@ class NotificationsService extends ChangeNotifier {
}
}
- void _checkTrades() async {
+ Future<void> _checkTrades() async {
for (final notification in _watchedChangeNowTradeNotifications) {
final id = notification.changeNowId!;
@@ -243,7 +242,7 @@ class NotificationsService extends ChangeNotifier {
);
if (trades.isEmpty) {
- return;
+ continue;
}
final oldTrade = trades.first;
late final ExchangeResponse<Trade> response;
@@ -252,11 +251,11 @@ class NotificationsService extends ChangeNotifier {
final exchange = Exchange.fromName(oldTrade.exchangeName);
response = await exchange.updateTrade(oldTrade);
} catch (_) {
- return;
+ continue;
}
if (response.value == null) {
- return;
+ continue;
}
final trade = response.value!;
@@ -371,11 +370,10 @@ class NotificationsService extends ChangeNotifier {
}
Future<void> markAsRead(int id, bool shouldNotifyListeners) async {
- final model =
- DB.instance.get<NotificationModel>(
- boxName: DB.boxNameNotifications,
- key: id,
- )!;
+ final model = DB.instance.get<NotificationModel>(
+ boxName: DB.boxNameNotifications,
+ key: id,
+ )!;
await DB.instance.put<NotificationModel>(
boxName: DB.boxNameNotifications,
key: model.id,
### lib/services/price.dart
@@ -159,16 +159,14 @@ class PriceAPI {
for (final map in coinGeckoData) {
final String coinName = map["name"] as String;
- late CryptoCurrency coin;
- try {
- coin = AppConfig.getCryptoCurrencyByPrettyName(
- coinName == "Factor" ? "Fact0rn" : coinName,
- );
- } catch (e, s) {
+ final coins = AppConfig.coins.where(
+ (coin) =>
+ coin.network == CryptoCurrencyNetwork.main &&
+ _coinToIdMap[coin.runtimeType] == map["id"],
+ );
+ if (coins.isEmpty) {
Logging.instance.e(
"Failed to find matching app coin for $coinName. Moving on",
- error: e,
- stackTrace: s,
);
continue;
}
@@ -179,9 +177,13 @@ class PriceAPI {
? double.parse(map["price_change_percentage_24h"].toString())
: 0.0;
- result[coin] = (value: price, change24h: change24h);
+ for (final coin in coins) {
+ result[coin] = (value: price, change24h: change24h);
+ }
} catch (_) {
- result.remove(coin);
+ for (final coin in coins) {
+ result.remove(coin);
+ }
}
}
### lib/services/wallets.dart
@@ -24,7 +24,10 @@ import '../utilities/prefs.dart';
import '../utilities/stack_file_system.dart';
import '../wallets/crypto_currency/crypto_currency.dart';
import '../wallets/crypto_currency/intermediate/cryptonote_currency.dart';
+import '../wallets/crypto_currency/intermediate/frost_currency.dart';
+import '../wallets/isar/models/frost_wallet_info.dart';
import '../wallets/isar/models/wallet_info.dart';
+import '../wallets/wallet/impl/bitcoin_frost_wallet.dart';
import '../wallets/wallet/impl/epiccash_wallet.dart';
import '../wallets/wallet/impl/mimblewimblecoin_wallet.dart';
import '../wallets/wallet/intermediate/cryptonote_wallet.dart';
@@ -108,6 +111,7 @@ class Wallets {
SecureStorageInterface secureStorage,
) async {
final walletId = info.walletId;
+ final isFrostWallet = info.coin is FrostCurrency;
Logging.instance.d("deleteWallet called with walletId=$walletId");
final wallet = _wallets[walletId];
@@ -123,6 +127,13 @@ class Wallets {
key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId),
);
+ if (isFrostWallet) {
+ await BitcoinFrostWallet.deleteSecureStorage(
+ walletId: walletId,
+ secureStorage: secureStorage,
+ );
+ }
+
if (info.coin is CryptonoteCurrency) {
await _deleteCryptonoteWalletFilesHelper(info);
} else if (info.coin is Epiccash) {
@@ -184,6 +195,9 @@ class Wallets {
}
await mainDB.isar.writeTxn(() async {
+ if (isFrostWallet) {
+ await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId);
+ }
await mainDB.isar.walletInfo.deleteByWalletId(walletId);
});
@@ -574,9 +588,8 @@ class Wallets {
);
} else if (walletInitFutures.isNotEmpty) {
unawaited(
- Future.wait(
- walletInitFutures,
- ).then((value) => _refreshFutures(idsToRefresh)),
+ Future.wait(walletInitFutures)
+ .then((value) => _refreshFutures(idsToRefresh)),
);
} else if (walletsToInitLinearly.isNotEmpty) {
unawaited(_initLinearly(walletsToInitLinearly));
@@ -669,8 +682,24 @@ class Wallets {
Future<void> _deleteWallet(String walletId) async {
// TODO proper clean up of other wallet data in addition to the following
- await mainDB.isar.writeTxn(
- () async => await mainDB.isar.walletInfo.deleteByWalletId(walletId),
- );
+ final info = await mainDB.isar.walletInfo
+ .where()
+ .walletIdEqualTo(walletId)
+ .findFirst();
+ final isFrostWallet =
+ info != null &&
+ AppConfig.getCryptoCurrencyFor(info.coinName) is FrostCurrency;
+ if (isFrostWallet) {
+ await BitcoinFrostWallet.deleteSecureStorage(
+ walletId: walletId,
+ secureStorage: nodeService.secureStorageInterface,
+ );
+ }
+ await mainDB.isar.writeTxn(() async {
+ if (isFrostWallet) {
+ await mainDB.isar.frostWalletInfo.deleteByWalletId(walletId);
+ }
+ await mainDB.isar.walletInfo.deleteByWalletId(walletId);
+ });
}
}
### lib/utilities/address_utils.dart
@@ -24,6 +24,9 @@ class AddressUtils {
'recipient_name',
'tx_description',
'op_return', // For Rosen Bridge and other OP_RETURN protocols.
+ 'memo', // Stellar SEP-0007.
+ 'dt', // XRP destination tag.
+ 'destination_tag',
// TODO [prio=med]: Add more recognized params for other coins.
};
@@ -76,6 +79,8 @@ class AddressUtils {
result["tx_description"] = Uri.decodeComponent(u.fragment);
}
}
+ } on FormatException {
+ rethrow;
} catch (e, s) {
Logging.instance.d(
"Exception caught in parseUri($uri): $e",
@@ -95,58 +100,51 @@ class AddressUtils {
switch (lowerKey) {
case 'amount':
case 'tx_amount':
- result['amount'] = _normalizeAmount(value);
+ final normalized = _normalizeAmount(value);
+ if (normalized == null) {
+ throw FormatException("Invalid payment URI amount: $value");
+ }
+ result['amount'] = normalized;
break;
case 'label':
case 'recipient_name':
- result['label'] = Uri.decodeComponent(value);
+ result['label'] = value;
break;
case 'message':
case 'tx_description':
- result['message'] = Uri.decodeComponent(value);
+ result['message'] = value;
break;
case 'tx_payment_id':
- result['tx_payment_id'] = Uri.decodeComponent(value);
+ result['tx_payment_id'] = value;
break;
default:
- result[lowerKey] = Uri.decodeComponent(value);
+ result[lowerKey] = value;
}
} else {
// Include unrecognized parameters as-is.
- result[key] = Uri.decodeComponent(value);
+ result[key] = value;
}
});
return result;
}
- /// Normalizes amount value to a standard format.
- static String _normalizeAmount(String amount) {
- // Remove any non-numeric characters except for '.'
- final sanitized = amount.replaceAll(RegExp(r'[^\d.]'), '');
- // Ensure only one decimal point
- final parts = sanitized.split('.');
- if (parts.length > 2) {
- return '${parts[0]}.${parts.sublist(1).join()}';
- }
- return sanitized;
+ static String? _normalizeAmount(String amount) {
+ final trimmed = amount.trim();
+ return RegExp(r'^(\d+(\.\d+)?|\.\d+)$').hasMatch(trimmed) ? trimmed : null;
}
/// Centralized method to handle various cryptocurrency URIs and return a common object.
///
/// Returns null on failure to parse
static PaymentUriData? parsePaymentUri(String uri, {Logging? logging}) {
// hacky check its not just a bcash, ecash, or xel address
- final parts = uri.split(":");
- if (parts.length == 2) {
- if ([
- "xel",
- "bitcoincash",
- "bchtest",
- "ecash",
- "ectest",
- ].contains(parts.first.toLowerCase())) {
- return null;
- }
+ const cashAddrSchemes = {"bitcoincash", "bchtest", "ecash", "ectest"};
+ final parsedUri = Uri.tryParse(uri);
+ final scheme = parsedUri?.scheme.toLowerCase();
+ if (parsedUri != null &&
+ (scheme == "xel" ||
+ (!parsedUri.hasQuery && cashAddrSchemes.contains(scheme)))) {
+ return null;
}
try {
@@ -155,13 +153,16 @@ class AddressUtils {
// Normalize the URI scheme.
final String scheme = parsedData['scheme'] ?? '';
parsedData.remove('scheme');
+ final address = parsedData['address']!.trim();
// Filter out unrecognized parameters.
final filteredParams = _filterParams(parsedData);
return PaymentUriData(
scheme: scheme,
- address: parsedData['address']!.trim(),
+ address: cashAddrSchemes.contains(scheme)
+ ? "$scheme:$address".toLowerCase()
+ : address,
amount: filteredParams['amount'] ?? filteredParams['tx_amount'],
label: filteredParams['label'] ?? filteredParams['recipient_name'],
message: filteredParams['message'] ?? filteredParams['tx_description'],
@@ -192,27 +193,32 @@ class AddressUtils {
uriString = "$scheme:$address";
}
- if (scheme.toLowerCase() == "monero") {
- // Handle Monero-specific formatting.
- if (filteredParams.containsKey("tx_description")) {
- final description = filteredParams.remove("tx_description")!;
- if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
- uriString += "#${Uri.encodeComponent(description)}";
- } else if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
- } else {
- // General case for other cryptocurrencies.
- if (filteredParams.isNotEmpty) {
- uriString += Uri(queryParameters: filteredParams).toString();
- }
+ if (filteredParams.isNotEmpty) {
+ uriString += Uri(queryParameters: filteredParams).toString();
}
return uriString;
}
+ static String buildPaymentUriString({
+ required String scheme,
+ required String address,
+ String? amount,
+ String? message,
+ }) {
+ final normalizedScheme = scheme.toLowerCase();
+ final usesMoneroParameters =
+ normalizedScheme == "monero" || normalizedScheme == "wownero";
+ final params = <String, String>{
+ if (amount != null && amount.isNotEmpty)
+ usesMoneroParameters ? "tx_amount" : "amount": amount,
+ if (message != null && message.isNotEmpty)
+ usesMoneroParameters ? "tx_description" : "message": message,
+ };
+
+ return buildUriString(scheme, address, params);
+ }
+
/// returns empty if bad data
static Map<String, dynamic> decodeQRSeedData(String data) {
Map<String, dynamic> result = {};
@@ -385,6 +391,20 @@ class PaymentUriData {
scheme ?? "", // empty will just return null
);
+ String? get memo {
+ for (final value in [
+ paymentId,
+ additionalParams["memo"],
+ additionalParams["dt"],
+ additionalParams["destination_tag"],
+ ]) {
+ if (value?.isNotEmpty == true) {
+ return value;
+ }
+ }
+ return null;
+ }
+
PaymentUriData({
required this.address,
this.scheme,
### lib/utilities/amount/amount.dart
@@ -32,35 +32,122 @@ class Amount {
fractionDigits: fractionDigits,
);
- static Amount? tryParseFiatString(String value, {required String locale}) {
- final parts = value.split(" ");
+ static Decimal? tryParseEditableDecimal(
+ String value, {
+ required String locale,
+ }) {
+ return _tryParseCanonicalDecimal(
+ _normalizeEditableDecimal(value, locale: locale),
+ );
+ }
+
+ static Amount? tryParseEditableAmount(
+ String value, {
+ required String locale,
+ required int fractionDigits,
+ }) {
+ return tryParseCanonicalAmount(
+ _normalizeEditableDecimal(value, locale: locale),
+ fractionDigits: fractionDigits,
+ );
+ }
- if (parts.first.isEmpty) {
+ static final RegExp _canonicalDecimalPattern = RegExp(
+ r'^(?:\d+(?:\.\d+)?|\.\d+)$',
+ );
+
+ static Decimal? _tryParseCanonicalDecimal(String value) {
+ if (!_canonicalDecimalPattern.hasMatch(value)) {
return null;
}
- String str = parts.first;
- if (str.startsWith(RegExp(r'[+-]'))) {
- str = str.substring(1);
+ return Decimal.tryParse(value);
+ }
+
+ static Amount? tryParseCanonicalAmount(
+ String value, {
+ required int fractionDigits,
+ bool truncateOverprecision = false,
+ }) {
+ if (fractionDigits < 0) {
+ return null;
}
- if (str.isEmpty) {
+ final decimal = _tryParseCanonicalDecimal(value);
+ if (decimal == null) {
return null;
}
- // get number symbols for decimal place and group separator
- final numberSymbols = Util.getSymbolsFor(locale: locale);
+ // Excess trailing zeros are fine as long as the value stays exactly
+ // representable; the isInteger check below rejects real overprecision
+ // unless the caller opted into truncation (e.g. externally supplied
+ // QR/URI amounts).
+ final atomicValue = decimal.shift(fractionDigits);
+ if (!atomicValue.isInteger && !truncateOverprecision) {
+ return null;
+ }
- final groupSeparator = numberSymbols?.GROUP_SEP ?? ",";
- final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? ".";
+ return Amount(
+ rawValue: atomicValue.toBigInt(),
+ fractionDigits: fractionDigits,
+ );
+ }
+
+ static String formatEditableDecimal(Decimal value, {required String locale}) {
+ return value.toString().replaceFirst(".", _decimalSeparator(locale));
+ }
+
+ static String formatFixedDecimal(
+ Decimal value, {
+ required int fractionDigits,
+ required String locale,
+ }) {
+ if (fractionDigits < 0) {
+ throw ArgumentError.value(fractionDigits, "fractionDigits");
+ }
+ return value
+ .toStringAsFixed(fractionDigits)
+ .replaceFirst(".", _decimalSeparator(locale));
+ }
- str = str.replaceAll(groupSeparator, "");
+ static String relocalizeEditableDecimal(
+ String value, {
+ required String sourceLocale,
+ required String targetLocale,
+ }) {
+ return value.replaceAll(
+ _decimalSeparator(sourceLocale),
+ _decimalSeparator(targetLocale),
+ );
+ }
- final decimalString = str.replaceFirst(decimalSeparator, ".");
+ static Amount? tryParseFiatString(String value, {required String locale}) {
+ return tryParseEditableAmount(value, locale: locale, fractionDigits: 2);
+ }
- return Decimal.tryParse(decimalString)?.toAmount(fractionDigits: 2);
+ static String _normalizeEditableDecimal(
+ String value, {
+ required String locale,
+ }) {
+ final decimalSeparator = _decimalSeparator(locale);
+ // Editable input accepts only the locale's decimal separator.
+ if (decimalSeparator != "." && value.contains(".")) {
+ return "";
+ }
+ String normalized = value.replaceAll(decimalSeparator, ".");
+ // A single trailing separator after digits reads as the number typed so
+ // far ("1." == 1); separator-only input stays invalid.
+ if (normalized.endsWith(".") &&
+ normalized.length > 1 &&
+ !normalized.substring(0, normalized.length - 1).contains(".")) {
+ normalized = normalized.substring(0, normalized.length - 1);
+ }
+ return normalized;
}
+ static String _decimalSeparator(String locale) =>
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+
// ===========================================================================
// ======= Instance properties ===============================================
### lib/utilities/amount/amount_field_relocalization.dart
@@ -0,0 +1,80 @@
+import 'package:flutter/widgets.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../providers/global/locale_provider.dart';
+import 'amount.dart';
+
+typedef ProviderListen<T> = void Function(
+ ProviderListenable<T> provider,
+ void Function(T? previous, T next) listener,
+);
+
+/// Rewrites a controller's decimal separator while preserving its selection.
+void relocalizeAmountController(
+ TextEditingController controller, {
+ required String sourceLocale,
+ required String targetLocale,
+}) {
+ final value = controller.value;
+ final text = Amount.relocalizeEditableDecimal(
+ value.text,
+ sourceLocale: sourceLocale,
+ targetLocale: targetLocale,
+ );
+ if (text == value.text) return;
+
+ int mapOffset(int offset) {
+ int safeOffset = offset;
+ if (safeOffset < 0) {
+ safeOffset = 0;
+ } else if (safeOffset > value.text.length) {
+ safeOffset = value.text.length;
+ }
+ return Amount.relocalizeEditableDecimal(
+ value.text.substring(0, safeOffset),
+ sourceLocale: sourceLocale,
+ targetLocale: targetLocale,
+ ).length;
+ }
+
+ final selection = value.selection.isValid
+ ? TextSelection(
+ baseOffset: mapOffset(value.selection.baseOffset),
+ extentOffset: mapOffset(value.selection.extentOffset),
+ affinity: value.selection.affinity,
+ isDirectional: value.selection.isDirectional,
+ )
+ : value.selection;
+ controller.value = value.copyWith(
+ text: text,
+ selection: selection,
+ composing: TextRange.empty,
+ );
+}
+
+/// Rewrites the decimal separator in [controllers] when the app locale
+/// changes so their text stays parseable by the locale-strict amount
+/// parsers, then invokes [onRelocalized] so the caller can re-run any
+/// parsing/validation that cached state from the old text.
+///
+/// Must be called from a widget's build method with `ref.listen`.
+void listenForAmountRelocalization(
+ ProviderListen<String> listen, {
+ required List<TextEditingController> controllers,
+ VoidCallback? onRelocalized,
+}) {
+ listen(localeServiceChangeNotifierProvider.select((value) => value.locale), (
+ previous,
+ next,
+ ) {
+ if (previous == null || previous == next) return;
+ for (final controller in controllers) {
+ relocalizeAmountController(
+ controller,
+ sourceLocale: previous,
+ targetLocale: next,
+ );
+ }
+ onRelocalized?.call();
+ });
+}
### lib/utilities/amount/amount_formatter.dart
@@ -66,7 +66,11 @@ class AmountFormatter {
);
}
- Amount? tryParse(String string, {Contract? tokenContract}) {
+ String formatEditable(Amount amount) {
+ return unit.formatEditable(amount: amount, locale: locale);
+ }
+
+ Amount? tryParseEditable(String string, {Contract? tokenContract}) {
return unit.tryParse(
string,
locale: locale,
### lib/utilities/amount/amount_input_formatter.dart
@@ -1,98 +1,206 @@
import 'dart:math';
import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
import '../util.dart';
import 'amount_unit.dart';
class AmountInputFormatter extends TextInputFormatter {
+ final TextEditingController controller;
final int decimals;
final String locale;
final AmountUnit? unit;
AmountInputFormatter({
+ required this.controller,
required this.decimals,
required this.locale,
this.unit,
- });
+ }) : assert(decimals >= 0);
+
+ late final String _decimalSeparator =
+ Util.getSymbolsFor(locale: locale)?.DECIMAL_SEP ?? ".";
+
+ late final int _maximumFractionDigits = unit == null
+ ? max(decimals, 0)
+ : max(decimals - unit!.shift, 0);
+
+ // Formatters are frequently constructed inline in build methods that
+ // rebuild per keystroke, so the compiled patterns are cached globally
+ // instead of per instance.
+ static final Map<(String, int), RegExp> _patternCache = {};
+
+ late final RegExp _validPattern = _patternCache.putIfAbsent(
+ (_decimalSeparator, _maximumFractionDigits),
+ () => _maximumFractionDigits == 0
+ ? RegExp(r'^\d*$')
+ : RegExp(
+ '^\\d*(?:${RegExp.escape(_decimalSeparator)}\\d{0,$_maximumFractionDigits})?\$',
+ ),
+ );
- @override
- TextEditingValue formatEditUpdate(
- TextEditingValue oldValue,
- TextEditingValue newValue,
- ) {
- // get number symbols for decimal place and group separator
- final numberSymbols = Util.getSymbolsFor(locale: locale);
+ static final Expando<_AmountInputRecovery> _recoveryCache = Expando();
- final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? ".";
- final groupSeparator = numberSymbols?.GROUP_SEP ?? ",";
+ (String, int, int?) get _configuration => (locale, decimals, unit?.shift);
- String newText = newValue.text.replaceAll(groupSeparator, "");
+ _AmountInputRecovery? get _activeRecovery {
+ final recovery = _recoveryCache[controller];
+ return recovery?.configuration == _configuration ? recovery : null;
+ }
- final selectionIndexFromTheRight =
- newValue.text.length - newValue.selection.end;
+ bool _continuesActiveComposition(
+ _AmountInputRecovery recovery,
+ TextEditingValue oldValue,
+ ) =>
+ !oldValue.composing.isCollapsed &&
+ recovery.composingText == oldValue.text;
- String? fraction;
- if (newText.contains(decimalSeparator)) {
- final parts = newText.split(decimalSeparator);
+ void _clearRecovery() => _recoveryCache[controller] = null;
- if (parts.length > 2) {
- return oldValue;
- }
+ void _rememberComposition(TextEditingValue value, String composingText) {
+ if (value.text.isEmpty) {
+ _clearRecovery();
+ return;
+ }
- final fractionDigits =
- unit == null ? decimals : max(decimals - unit!.shift, 0);
+ final selection = value.selection.isValid
+ ? TextSelection(
+ baseOffset: min(
+ max(value.selection.baseOffset, 0),
+ value.text.length,
+ ),
+ extentOffset: min(
+ max(value.selection.extentOffset, 0),
+ value.text.length,
+ ),
+ affinity: value.selection.affinity,
+ isDirectional: value.selection.isDirectional,
+ )
+ : TextSelection.collapsed(offset: value.text.length);
+ _recoveryCache[controller] = _AmountInputRecovery(
+ configuration: _configuration,
+ value: value.copyWith(selection: selection, composing: TextRange.empty),
+ composingText: composingText,
+ );
+ }
- if (newText.startsWith(decimalSeparator)) {
- if (newText.length - 1 > fractionDigits) {
- newText = newText.substring(0, fractionDigits + 1);
+ @override
+ TextEditingValue formatEditUpdate(
+ TextEditingValue oldValue,
+ TextEditingValue newValue,
+ ) {
+ if (!newValue.composing.isCollapsed) {
+ if (_validPattern.hasMatch(oldValue.text)) {
+ _rememberComposition(oldValue, newValue.text);
+ } else {
+ final recovery = _activeRecovery;
+ if (recovery != null &&
+ _continuesActiveComposition(recovery, oldValue)) {
+ _recoveryCache[controller] = _AmountInputRecovery(
+ configuration: _configuration,
+ value: recovery.value,
+ composingText: newValue.text,
+ );
+ } else {
+ _clearRecovery();
}
-
- return TextEditingValue(
- text: newText,
- selection: TextSelection.collapsed(
- offset: newText.length - selectionIndexFromTheRight,
- ),
- );
}
+ return newValue;
+ }
- newText = parts.first;
- if (parts.length == 2) {
- fraction = parts.last;
- } else {
- fraction = "";
- }
+ if (_validPattern.hasMatch(newValue.text)) {
+ _clearRecovery();
+ return newValue;
+ }
- if (fraction.length > fractionDigits) {
- fraction = fraction.substring(0, fractionDigits);
- }
+ final recovery = _activeRecovery;
+ if (recovery != null && _continuesActiveComposition(recovery, oldValue)) {
+ _clearRecovery();
+ return recovery.value;
}
- String newString;
- final val = BigInt.tryParse(newText);
- if (val == null || val < BigInt.one) {
- newString = newText;
- } else {
- // insert group separator
- final regex = RegExp(r'\B(?=(\d{3})+(?!\d))');
- newString = newText.replaceAllMapped(
- regex,
- (m) => "${m.group(0)}${numberSymbols?.GROUP_SEP ?? ","}",
- );
+ final oldTextIsValid = _validPattern.hasMatch(oldValue.text);
+ final isDeletingFromInvalidText =
+ !oldTextIsValid &&
+ newValue.text.length < oldValue.text.length &&
+ _canResultFromDeletion(oldValue.text, newValue.text);
+ if (isDeletingFromInvalidText) {
+ _clearRecovery();
+ return newValue;
}
- if (fraction != null) {
- newString += decimalSeparator;
- if (fraction.isNotEmpty) {
- newString += fraction;
- }
+ if (oldTextIsValid) {
+ _clearRecovery();
+ return oldValue;
}
+ // Both texts are invalid, but are not the commit of the active composing
+ // value. A remembered value from an unrelated edit (or an earlier
+ // formatter configuration) must not replace the current input.
+ _clearRecovery();
+ // Never strip characters from the middle — joining the surrounding digits
+ // would silently change the value ("1.5" must not become "15").
+ return _validPrefix(newValue);
+ }
+
+ TextEditingValue _validPrefix(TextEditingValue value) {
+ const asciiZeroCodeUnit = 0x30;
+ const asciiNineCodeUnit = 0x39;
+ bool separatorSeen = false;
+ int fractionDigits = 0;
+ int end = 0;
+ for (; end < value.text.length; end++) {
+ final char = value.text[end];
+ final codeUnit = char.codeUnitAt(0);
+ if (codeUnit >= asciiZeroCodeUnit && codeUnit <= asciiNineCodeUnit) {
+ if (separatorSeen) {
+ if (fractionDigits >= _maximumFractionDigits) break;
+ fractionDigits++;
+ }
+ } else if (!separatorSeen &&
+ _maximumFractionDigits > 0 &&
+ char == _decimalSeparator) {
+ separatorSeen = true;
+ } else {
+ break;
+ }
+ }
+ final text = value.text.substring(0, end);
return TextEditingValue(
- text: newString,
+ text: text,
selection: TextSelection.collapsed(
- offset: newString.length - selectionIndexFromTheRight,
+ offset: min(
+ value.selection.isValid ? value.selection.end : text.length,
+ text.length,
+ ),
),
);
}
+
+ bool _canResultFromDeletion(String oldText, String newText) {
+ int newIndex = 0;
+ for (
+ int oldIndex = 0;
+ oldIndex < oldText.length && newIndex < newText.length;
+ oldIndex++
+ ) {
+ if (oldText.codeUnitAt(oldIndex) == newText.codeUnitAt(newIndex)) {
+ newIndex++;
+ }
+ }
+ return newIndex == newText.length;
+ }
+}
+
+class _AmountInputRecovery {
+ final (String, int, int?) configuration;
+ final TextEditingValue value;
+ final String composingText;
+
+ const _AmountInputRecovery({
+ required this.configuration,
+ required this.value,
+ required this.composingText,
+ });
}
### lib/utilities/amount/amount_unit.dart
@@ -196,51 +196,46 @@ extension AmountUnitExt on AmountUnit {
}
}
+ static final RegExp _rejectedInputChars = RegExp(r'[+\-~ \x09-\x0D]');
+
+ /// Parses user-editable amount text (digits plus the locale's decimal
+ /// separator only). Display strings (grouped, unit-suffixed, or
+ /// "~"-prefixed) are intentionally rejected: formatted display output
+ /// must never be re-parsed as input.
Amount? tryParse(
String value, {
required String locale,
required CryptoCurrency coin,
- Contract? tokenContract,
- bool overrideWithDecimalPlacesFromString = false,
+ Contract? tokenContract,
}) {
- final precisionLost = value.startsWith("~");
-
- final parts = (precisionLost ? value.substring(1) : value).split(" ");
-
- if (parts.first.isEmpty) {
+ if (value.contains(_rejectedInputChars)) {
return null;
}
- String str = parts.first;
- if (str.startsWith(RegExp(r'[+-]'))) {
- str = str.substring(1);
- }
-
- if (str.isEmpty) {
- return null;
- }
-
- // get number symbols for decimal place and group separator
- final numberSymbols = Util.getSymbolsFor(locale: locale);
-
- final groupSeparator = numberSymbols?.GROUP_SEP ?? ",";
- final decimalSeparator = numberSymbols?.DECIMAL_SEP ?? ".";
-
- str = str.replaceAll(groupSeparator, "");
-
- final decimalString = str.replaceFirst(decimalSeparator, ".");
- final Decimal? decimal = Decimal.tryParse(decimalString);
+ final decimalPlaces = tokenContract?.decimals ?? coin.fractionDigits;
+ final realShift = math.min(shift, decimalPlaces);
- if (decimal == null) {
+ final parsedUnitAmount = Amount.tryParseEditableAmount(
+ value,
+ locale: locale,
+ fractionDigits: decimalPlaces - realShift,
+ );
+ if (parsedUnitAmount == null) {
return null;
}
- final decimalPlaces = overrideWithDecimalPlacesFromString
- ? decimal.scale
- : tokenContract?.decimals ?? coin.fractionDigits;
- final realShift = math.min(shift, decimalPlaces);
+ return Amount(
+ rawValue: parsedUnitAmount.raw,
+ fractionDigits: decimalPlaces,
+ );
+ }
- return decimal.shift(0 - realShift).toAmount(fractionDigits: decimalPlaces);
+ String formatEditable({required Amount amount, required String locale}) {
+ final realShift = math.min(shift, amount.fractionDigits);
+ return Amount.formatEditableDecimal(
+ amount.decimal.shift(realShift),
+ locale: locale,
+ );
}
String displayAmount({
### lib/utilities/custom_text_selection_controls.dart
@@ -16,7 +16,7 @@ class CustomMaterialTextSelectionControls
CustomMaterialTextSelectionControls({required this.onPaste});
ValueChanged<TextSelectionDelegate> onPaste;
@override
- Future<void> handlePaste(final TextSelectionDelegate delegate) async {
+ Future<void> handlePaste(TextSelectionDelegate delegate) async {
return onPaste(delegate);
}
}
@@ -26,7 +26,7 @@ class CustomCupertinoTextSelectionControls
CustomCupertinoTextSelectionControls({required this.onPaste});
ValueChanged<TextSelectionDelegate> onPaste;
@override
- Future<void> handlePaste(final TextSelectionDelegate delegate) async {
+ Future<void> handlePaste(TextSelectionDelegate delegate) async {
return onPaste(delegate);
}
}
### lib/utilities/desktop_password_service.dart
@@ -60,13 +60,23 @@ class DPS {
}
try {
- _handler = await StorageCryptoHandler.fromNewPassphrase(
+ if (await _get(key: _kKeyBlobKey) != null) {
+ throw Exception(
+ "DPS: attempted to overwrite an existing keyBlob with a new one",
+ );
+ }
+
+ final handler = await StorageCryptoHandler.fromNewPassphrase(
passphrase,
kLatestBlobVersion,
);
+ final keyBlob = await handler.getKeyBlob();
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
+ // The blob is the password-exists commit marker. Store its version first
+ // so a failed blob write leaves a safe, retryable version-only state.
await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: keyBlob);
+ _handler = handler;
} catch (e, s) {
Logging.instance.e(
"${_getMessageFromException(e)}\n$s",
@@ -89,21 +99,36 @@ class DPS {
if (keyBlob == null) {
throw Exception(
- "DPS: failed to find keyBlob while attempting to initialize with existing passphrase",
+ "DPS: failed to find keyBlob while attempting to initialize with"
+ " existing passphrase",
);
}
- final blobVersion = await _getStoredKeyBlobVersion();
- _handler = await StorageCryptoHandler.fromExisting(
+ final versionHint = await _getStoredKeyBlobVersion();
+ final authenticated = await _authenticateKeyBlob(
passphrase,
keyBlob,
- blobVersion,
+ versionHint,
);
- if (blobVersion < kLatestBlobVersion) {
- // update blob
- await _handler!.resetPassphrase(passphrase, kLatestBlobVersion);
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
- await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ _handler = authenticated.handler;
+
+ if (authenticated.version < kLatestBlobVersion) {
+ await _tryUpgradeKeyBlob(
+ passphrase: passphrase,
+ keyBlob: keyBlob,
+ version: authenticated.version,
+ );
+ } else if (versionHint != authenticated.version) {
+ try {
+ await _updateStoredKeyBlobVersion(authenticated.version);
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to repair key blob version metadata",
+ error: e,
+ stackTrace: s,
+ );
+ }
}
+ await _tryCompactPasswordStorage();
} catch (e, s) {
Logging.instance.e(
"${_getMessageFromException(e)}\n$s",
@@ -122,8 +147,8 @@ class DPS {
// no passphrase key blob found so any passphrase is technically bad
return false;
}
- final blobVersion = await _getStoredKeyBlobVersion();
- await StorageCryptoHandler.fromExisting(passphrase, keyBlob, blobVersion);
+ final versionHint = await _getStoredKeyBlobVersion();
+ await _authenticateKeyBlob(passphrase, keyBlob, versionHint);
// existing passphrase matches key blob
return true;
} catch (e, s) {
@@ -142,21 +167,33 @@ class DPS {
String passphraseNew,
) async {
try {
+ if (_handler == null) {
+ return false;
+ }
+
final keyBlob = await _get(key: _kKeyBlobKey);
if (keyBlob == null) {
// no passphrase key blob found so any passphrase is technically bad
return false;
}
- if (!(await verifyPassphrase(passphraseOld))) {
- return false;
- }
+ final versionHint = await _getStoredKeyBlobVersion();
+ final authenticated = await _authenticateKeyBlob(
+ passphraseOld,
+ keyBlob,
+ versionHint,
+ );
+ final newHandler = authenticated.handler;
+ await newHandler.resetPassphrase(passphraseNew, kLatestBlobVersion);
+ final newBlob = await newHandler.getKeyBlob();
- final blobVersion = await _getStoredKeyBlobVersion();
- await _handler!.resetPassphrase(passphraseNew, blobVersion);
- await _put(key: _kKeyBlobKey, value: await _handler!.getKeyBlob());
- await _updateStoredKeyBlobVersion(blobVersion);
+ // The version may be temporarily ahead if the blob write fails. Readers
+ // probe supported versions, so the old blob remains usable and retryable.
+ await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: newBlob);
+ _handler = newHandler;
+ await _tryCompactPasswordStorage();
// successfully updated passphrase
return true;
@@ -181,7 +218,118 @@ class DPS {
}
Future<void> _updateStoredKeyBlobVersion(int version) async {
- await _put(key: _kKeyBlobVersionKey, value: version.toString());
+ await _putAndVerify(key: _kKeyBlobVersionKey, value: version.toString());
+ }
+
+ Future<({StorageCryptoHandler handler, int version})> _authenticateKeyBlob(
+ String passphrase,
+ String keyBlob,
+ int versionHint,
+ ) async {
+ Object? lastError;
+ StackTrace? lastStackTrace;
+ final versions = <int>{versionHint};
+ for (int version = kLatestBlobVersion; version >= 1; version--) {
+ versions.add(version);
+ }
+
+ for (final version in versions) {
+ try {
+ return (
+ handler: await StorageCryptoHandler.fromExisting(
+ passphrase,
+ keyBlob,
+ version,
+ ),
+ version: version,
+ );
+ } on IncorrectPassphraseOrVersion catch (e, s) {
+ lastError = e;
+ lastStackTrace = s;
+ } on VersionError catch (e, s) {
+ lastError = e;
+ lastStackTrace = s;
+ }
+ }
+
+ Error.throwWithStackTrace(lastError!, lastStackTrace!);
+ }
+
+ Future<void> _tryUpgradeKeyBlob({
+ required String passphrase,
+ required String keyBlob,
+ required int version,
+ }) async {
+ try {
+ final upgradedHandler = await StorageCryptoHandler.fromExisting(
+ passphrase,
+ keyBlob,
+ version,
+ );
+ await upgradedHandler.resetPassphrase(passphrase, kLatestBlobVersion);
+ final upgradedBlob = await upgradedHandler.getKeyBlob();
+
+ await _updateStoredKeyBlobVersion(kLatestBlobVersion);
+ await _putAndVerify(key: _kKeyBlobKey, value: upgradedBlob);
+ _handler = upgradedHandler;
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: key blob upgrade failed; continuing with authenticated version",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+
+ Future<void> _tryCompactPasswordStorage() async {
+ Box<String>? box;
+ try {
+ box = await DB.instance.hive.openBox<String>(kBoxNameDesktopData);
+ await box.compact();
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to compact desktop password storage",
+ error: e,
+ stackTrace: s,
+ );
+ } finally {
+ try {
+ await box?.close();
+ } catch (e, s) {
+ Logging.instance.w(
+ "DPS: failed to close desktop password storage after compaction",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+ }
+
+ Future<void> _putAndVerify({
+ required String key,
+ required String value,
+ }) async {
+ try {
+ await _put(key: key, value: value);
+ } catch (e, s) {
+ try {
+ if (await _get(key: key) == value) {
+ Logging.instance.w(
+ "DPS: put($key) reported an error but persisted data was verified",
+ error: e,
+ stackTrace: s,
+ );
+ return;
+ }
+ } catch (_) {
+ // Preserve the original write error below.
+ }
+ Error.throwWithStackTrace(e, s);
+ }
+
+ if (await _get(key: key) != value) {
+ throw Exception("DPS: persisted value verification failed for $key");
+ }
}
Future<void> _put({required String key, required String value}) async {
@@ -191,6 +339,7 @@ class DPS {
await box.put(key, value);
} catch (e, s) {
Logging.instance.f("DPS failed put($key): ", error: e, stackTrace: s);
+ rethrow;
} finally {
await box?.close();
}
@@ -204,6 +353,7 @@ class DPS {
value = box.get(key);
} catch (e, s) {
Logging.instance.f("DPS failed get($key): ", error: e, stackTrace: s);
+ rethrow;
} finally {
await box?.close();
}
### lib/utilities/electrum_seed_utils.dart
@@ -12,8 +12,8 @@ abstract class ElectrumSeedUtils {
static const kSeedPrefix2faSegwit = "102"; // 2FA segwit
static Uint8List electrumMnemonicToSeedBytes(
- final String mnemonic, {
- final String passphrase = "",
+ String mnemonic, {
+ String passphrase = "",
}) {
final salt = Uint8List.fromList([
..."electrum".toUint8ListFromUtf8,
@@ -28,8 +28,8 @@ abstract class ElectrumSeedUtils {
// based on https://electrum.readthedocs.io/en/latest/seedphrase.html#version-number
static String electrumMnemonicVersion(
- final String mnemonic, {
- final String passphrase = "",
+ String mnemonic, {
+ String passphrase = "",
}) {
final normalized = normalize(mnemonic).toUint8ListFromUtf8;
@@ -40,17 +40,18 @@ abstract class ElectrumSeedUtils {
return hash.substring(0, length);
}
- static bool isNewSeed(final String mnemonic, {String prefix = kSeedPrefix}) {
+ static bool isNewSeed(String mnemonic, {String prefix = kSeedPrefix}) {
final normalized = normalize(mnemonic).toUint8ListFromUtf8;
final hash = _hmacHex(normalized);
return hash.startsWith(prefix);
}
- static String normalize(final String mnemonic) {
+ static String normalize(String mnemonic) {
final characters = String.fromCharCodes(
- nfkd(
- mnemonic,
- ).toLowerCase().runes.where((e) => !_kNonZeroCCCCodeUnits.contains(e)),
+ nfkd(mnemonic)
+ .toLowerCase()
+ .runes
+ .where((e) => !_kNonZeroCCCCodeUnits.contains(e)),
).split(RegExp(r"\s+")).join(" ").trim().split("");
final buffer = StringBuffer();
### lib/utilities/enums/fee_rate_type_enum.dart
@@ -11,6 +11,10 @@
enum FeeRateType { fast, average, slow, custom }
extension FeeRateTypeExt on FeeRateType {
+ bool get isCustom => this == FeeRateType.custom;
+
+ int? customSatsPerVByte(int satsPerVByte) => isCustom ? satsPerVByte : null;
+
String get prettyName {
switch (this) {
case FeeRateType.fast:
### lib/utilities/extensions/impl/cl_transaction.dart
@@ -11,7 +11,7 @@ extension CLTransactionExt on Transaction {
int vSize() => (weight() / 4).ceil();
- int _byteLength(final bool allowWitness) {
+ int _byteLength(bool allowWitness) {
final hasWitness = allowWitness && isWitness;
return (hasWitness ? 10 : 8) +
_encodingLength(inputs.length) +
@@ -37,17 +37,14 @@ extension CLTransactionExt on Transaction {
int _vectorSize(List<Uint8List> someVector) {
final length = someVector.length;
return _encodingLength(length) +
- someVector.fold(
- 0,
- (sum, witness) => sum + _varSliceSize(witness),
- );
+ someVector.fold(0, (sum, witness) => sum + _varSliceSize(witness));
}
int _encodingLength(int number) => number < 0xfd
? 1
: number <= 0xffff
- ? 3
- : number <= 0xffffffff
- ? 5
- : 9;
+ ? 3
+ : number <= 0xffffffff
+ ? 5
+ : 9;
}
### lib/utilities/extra_id_currency_support.dart
@@ -0,0 +1,25 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+/// Currencies whose custodial deposits commonly require a destination
+/// tag/memo ("extra ID") attached to the payout transaction. A payout sent
+/// to such a platform without its tag lands unattributed.
+abstract final class ExtraIdCurrencySupport {
+ static const Set<String> _tickers = {
+ "atom",
+ "eos",
+ "hbar",
+ "ton",
+ "xlm",
+ "xrp",
+ };
+
+ static bool mayRequire(String ticker) =>
+ _tickers.contains(ticker.trim().toLowerCase());
+}
### lib/utilities/git_status.dart
@@ -7,8 +7,6 @@ import '../../../themes/stack_colors.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
import '../app_config.dart';
-import '../wl_gen/generated/libepiccash_interface_impl.dart';
-import '../wl_gen/interfaces/libmwc_interface.dart';
const kGithubAPI = "https://api.github.com";
const kGithubSearch = "/search/commits";
@@ -17,71 +15,10 @@ const kGithubHead = "/repos";
enum CommitStatus { isHead, isOldCommit, notACommit, notLoaded }
abstract class GitStatus {
- static String get epicCashCommit => libEpic.getPluginVersion();
// static String get moneroCommit => monero_versions.getPluginVersion();
- static String get mimblewimblecoinCommit => libMwc.getPluginVersion();
static String get appCommitHash => AppConfig.commitHash;
- static CommitStatus? _cachedEpicStatus;
- static Future<CommitStatus> getEpicCommitStatus() async {
- if (_cachedEpicStatus != null) {
- return _cachedEpicStatus!;
- }
-
- final List<bool> results = await Future.wait([
- _doesCommitExist("cypherstack", "flutter_libepiccash", epicCashCommit),
- _isHeadCommit(
- "cypherstack",
- "flutter_libepiccash",
- "main",
- epicCashCommit,
- ),
- ]);
-
- final commitExists = results[0];
- final commitIsHead = results[1];
-
- if (commitExists && commitIsHead) {
- _cachedEpicStatus = CommitStatus.isHead;
- } else if (commitExists) {
- _cachedEpicStatus = CommitStatus.isOldCommit;
- } else {
- _cachedEpicStatus = CommitStatus.notACommit;
- }
-
- return _cachedEpicStatus!;
- }
-
- static CommitStatus? _cachedMimblewimblecoinStatus;
- static Future<CommitStatus> getMimblewimblecoinCommitStatus() async {
- if (_cachedMimblewimblecoinStatus != null) {
- return _cachedMimblewimblecoinStatus!;
- }
- final List<bool> results = await Future.wait([
- _doesCommitExist("cypherstack", "flutter_libmwc", mimblewimblecoinCommit),
- _isHeadCommit(
- "cypherstack",
- "flutter_libmwc",
- "main",
- mimblewimblecoinCommit,
- ),
- ]);
-
- final commitExists = results[0];
- final commitIsHead = results[1];
-
- if (commitExists && commitIsHead) {
- _cachedMimblewimblecoinStatus = CommitStatus.isHead;
- } else if (commitExists) {
- _cachedMimblewimblecoinStatus = CommitStatus.isOldCommit;
- } else {
- _cachedMimblewimblecoinStatus = CommitStatus.notACommit;
- }
-
- return _cachedMimblewimblecoinStatus!;
- }
-
//static CommitStatus? _cachedMoneroStatus;
//static Future<CommitStatus> getMoneroCommitStatus() async {
// if (_cachedMoneroStatus != null) {
### lib/utilities/integer_input.dart
@@ -0,0 +1,32 @@
+final _decimalIntegerPattern = RegExp(r'^-?[0-9]+$');
+
+int? tryParseIntegerInput(String text, {int? minimum, int? maximum}) {
+ assert(minimum == null || maximum == null || minimum <= maximum);
+
+ final normalized = text.trim();
+ if (!_decimalIntegerPattern.hasMatch(normalized)) {
+ return null;
+ }
+
+ final value = int.tryParse(normalized, radix: 10);
+ if (value == null ||
+ (minimum != null && value < minimum) ||
+ (maximum != null && value > maximum)) {
+ return null;
+ }
+
+ return value;
+}
+
+({bool isValid, int? value}) parseOptionalIntegerInput(
+ String text, {
+ int? minimum,
+ int? maximum,
+}) {
+ if (text.isEmpty) {
+ return (isValid: true, value: null);
+ }
+
+ final value = tryParseIntegerInput(text, minimum: minimum, maximum: maximum);
+ return (isValid: value != null, value: value);
+}
### lib/utilities/node_uri_util.dart
@@ -1,3 +1,5 @@
+bool isValidNodePort(int? port) => port != null && port > 0 && port <= 65535;
+
abstract interface class NodeQrData {
final String host;
final int port;
@@ -109,6 +111,7 @@ abstract final class NodeQrUtil {
switch (uri.scheme) {
case "xmrrpc":
+ if (!uri.hasPort) throw Exception("Uri has no port.");
return MoneroNodeQrData(
host: uri.host,
port: uri.port,
@@ -117,6 +120,7 @@ abstract final class NodeQrUtil {
label: query["label"],
);
case "wowrpc":
+ if (!uri.hasPort) throw Exception("Uri has no port.");
return WowneroNodeQrData(
host: uri.host,
port: uri.port,
### lib/utilities/util.dart
@@ -28,15 +28,59 @@ abstract class Util {
static const isArmLinux = bool.fromEnvironment("IS_ARM");
static final isTestEnv = Platform.environment["FLUTTER_TEST"] == "true";
- static Directory? libraryPath;
+ static final Map<String, NumberSymbols?> _numberSymbolsCache = {};
+
static double? screenWidth;
+ static bool? isIpad;
static NumberSymbols? getSymbolsFor({required String locale}) {
- return numberFormatSymbols[locale] as NumberSymbols? ??
- numberFormatSymbols[locale.replaceAll("-", "_")] as NumberSymbols? ??
- numberFormatSymbols[locale.substring(3).toLowerCase()]
- as NumberSymbols? ??
- numberFormatSymbols[locale.substring(0, 2)] as NumberSymbols?;
+ return _numberSymbolsCache.putIfAbsent(locale, () {
+ final exactSymbols = numberFormatSymbols[locale];
+ if (exactSymbols is NumberSymbols) {
+ return exactSymbols;
+ }
+
+ final localeParts = locale
+ .replaceAll("-", "_")
+ .split("_")
+ .where((part) => part.isNotEmpty)
+ .toList();
+ if (localeParts.isEmpty) {
+ return null;
+ }
+
+ final languageCode = localeParts.first.toLowerCase();
+ String? scriptCode;
+ String? regionCode;
+ for (final part in localeParts.skip(1)) {
+ if (scriptCode == null && RegExp(r'^[A-Za-z]{4}$').hasMatch(part)) {
+ scriptCode =
+ "${part[0].toUpperCase()}${part.substring(1).toLowerCase()}";
+ } else if (regionCode == null &&
+ RegExp(r'^(?:[A-Za-z]{2}|\d{3})$').hasMatch(part)) {
+ regionCode = part.toUpperCase();
+ }
+ }
+
+ final candidates = <String>{
+ [
+ languageCode,
+ if (scriptCode != null) scriptCode,
+ if (regionCode != null) regionCode,
+ ].join("_"),
+ if (regionCode != null) "${languageCode}_$regionCode",
+ if (scriptCode != null) "${languageCode}_$scriptCode",
+ languageCode,
+ };
+
+ for (final candidate in candidates) {
+ final symbols = numberFormatSymbols[candidate];
+ if (symbols is NumberSymbols) {
+ return symbols;
+ }
+ }
+ return null;
+ });
}
static bool get isDesktop {
@@ -45,20 +89,17 @@ abstract class Util {
return false;
}
- // special check for running under ipad mode in macos
- if (Platform.isIOS &&
- libraryPath != null &&
- !libraryPath!.path.contains("/var/mobile/")) {
+ if (isIpad == true) {
return true;
}
return Platform.isLinux || Platform.isMacOS || Platform.isWindows;
}
- static Future<bool> get isIPad async {
+ static Future<bool> get getIsIPad async {
final deviceInfo = (await DeviceInfoPlugin().deviceInfo);
if (deviceInfo is IosDeviceInfo) {
- return (deviceInfo).name?.toLowerCase().contains("ipad") == true;
+ return (deviceInfo).name.toLowerCase().contains("ipad") == true;
}
return false;
}
### lib/wallets/crypto_currency/coins/dash.dart
@@ -90,7 +90,7 @@ class Dash extends Bip39HDCurrency with ElectrumXCurrencyInterface {
@override
Amount get dustLimit =>
- Amount(rawValue: BigInt.from(1000000), fractionDigits: fractionDigits);
+ Amount(rawValue: BigInt.from(546), fractionDigits: fractionDigits);
@override
String get genesisHash {
### lib/wallets/crypto_currency/coins/firo.dart
@@ -13,6 +13,11 @@ import '../crypto_currency.dart';
import '../interfaces/electrumx_currency_interface.dart';
import '../intermediate/bip39_hd_currency.dart';
+bool _isBitcoinBech32Address(String address) {
+ final value = address.toLowerCase();
+ return value.startsWith("bc1") || value.startsWith("tb1");
+}
+
class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
Firo(super.network) {
_idMain = "firo";
@@ -106,7 +111,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
p2shPrefix: 0x07,
privHDPrefix: 0x0488ade4,
pubHDPrefix: 0x0488b21e,
- bech32Hrp: "bc",
+ bech32Hrp: "",
messagePrefix: '\x16Zcoin Signed Message:\n',
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
@@ -119,7 +124,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
p2shPrefix: 0xb2,
privHDPrefix: 0x04358394,
pubHDPrefix: 0x043587cf,
- bech32Hrp: "tb",
+ bech32Hrp: "",
messagePrefix: "\x16Zcoin Signed Message:\n",
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
@@ -188,11 +193,8 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
coinlib.Address.fromString(address, networkParams);
return true;
} catch (_) {
- if (validateSparkAddress(address)) {
- return true;
- } else {
- return isExchangeAddress(address);
- }
+ if (_isBitcoinBech32Address(address)) return false;
+ return isExchangeAddress(address) || validateSparkAddress(address);
}
}
@@ -301,9 +303,9 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
@override
AddressType? getAddressType(String address) {
- if (validateSparkAddress(address)) {
- return .spark;
- }
- return super.getAddressType(address);
+ if (_isBitcoinBech32Address(address)) return null;
+ final type = super.getAddressType(address);
+ if (type != null) return type;
+ return validateSparkAddress(address) ? .spark : null;
}
}
### lib/wallets/crypto_currency/coins/litecoin.dart
@@ -51,6 +51,9 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
// change this to change the number of confirms a tx needs in order to show as confirmed
int get minConfirms => 1;
+ @override
+ int get mwebPegoutMaturity => 6;
+
@override
bool get torSupport => true;
@@ -169,11 +172,10 @@ class Litecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
return (address: addr, addressType: AddressType.p2pkh);
case DerivePathType.bip49:
- final p2wpkhScript =
- coinlib.P2WPKHAddress.fromPublicKey(
- publicKey,
- hrp: networkParams.bech32Hrp,
- ).program.script;
+ final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey(
+ publicKey,
+ hrp: networkParams.bech32Hrp,
+ ).program.script;
final addr = coinlib.P2SHAddress.fromRedeemScript(
p2wpkhScript,
### lib/wallets/crypto_currency/coins/peercoin.dart
@@ -64,8 +64,7 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
String coinType;
switch (networkParams.wifPrefix) {
case 183: // PPC mainnet wif.
- coinType =
- "6"; // according to https://github.com/satoshilabs/slips/blob/master/slip-0044.md
+ coinType = "6"; // according to https://github.com/satoshilabs/slips/blob/master/slip-0044.md
break;
case 239: // PPC testnet wif.
coinType = "1";
@@ -165,11 +164,10 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
return (address: addr, addressType: AddressType.p2pkh);
case DerivePathType.bip49:
- final p2wpkhScript =
- coinlib.P2WPKHAddress.fromPublicKey(
- publicKey,
- hrp: networkParams.bech32Hrp,
- ).program.script;
+ final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey(
+ publicKey,
+ hrp: networkParams.bech32Hrp,
+ ).program.script;
final addr = coinlib.P2SHAddress.fromRedeemScript(
p2wpkhScript,
@@ -266,5 +264,5 @@ class Peercoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
int get transactionVersion => 3;
@override
- BigInt get defaultFeeRate => BigInt.from(5000);
+ BigInt get defaultFeeRate => BigInt.from(10000);
}
### lib/wallets/crypto_currency/coins/xelis.dart
@@ -83,7 +83,7 @@ class Xelis extends ElectrumCurrency {
isPrimary: isPrimary,
);
- case CryptoCurrencyNetwork.test:
+ case CryptoCurrencyNetwork.stage:
return NodeModel(
host: "stagenet-node.xelis.io",
port: 443,
### lib/wallets/crypto_currency/crypto_currency.dart
@@ -11,11 +11,11 @@ export 'coins/dash.dart';
export 'coins/dogecoin.dart';
export 'coins/ecash.dart';
export 'coins/epiccash.dart';
-export 'coins/mimblewimblecoin.dart';
export 'coins/ethereum.dart';
export 'coins/fact0rn.dart';
export 'coins/firo.dart';
export 'coins/litecoin.dart';
+export 'coins/mimblewimblecoin.dart';
export 'coins/monero.dart';
export 'coins/namecoin.dart';
export 'coins/nano.dart';
@@ -65,6 +65,7 @@ abstract class CryptoCurrency {
int get minConfirms;
int get minCoinbaseConfirms => minConfirms;
+ int? get mwebPegoutMaturity => null;
// TODO: [prio=low] could be handled differently as (at least) epiccash/mimblewimblecoin does not use this
String get genesisHash;
### lib/wallets/isar/models/frost_wallet_info.g.dart
@@ -66,7 +66,7 @@ const FrostWalletInfoSchema = CollectionSchema(
getId: _frostWalletInfoGetId,
getLinks: _frostWalletInfoGetLinks,
attach: _frostWalletInfoAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _frostWalletInfoEstimateSize(
### lib/wallets/isar/models/spark_coin.g.dart
@@ -110,7 +110,7 @@ const SparkCoinSchema = CollectionSchema(
getId: _sparkCoinGetId,
getLinks: _sparkCoinGetLinks,
attach: _sparkCoinAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _sparkCoinEstimateSize(
### lib/wallets/isar/models/token_wallet_info.g.dart
@@ -70,7 +70,7 @@ const TokenWalletInfoSchema = CollectionSchema(
getId: _tokenWalletInfoGetId,
getLinks: _tokenWalletInfoGetLinks,
attach: _tokenWalletInfoAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _tokenWalletInfoEstimateSize(
### lib/wallets/isar/models/wallet_info.g.dart
@@ -112,7 +112,7 @@ const WalletInfoSchema = CollectionSchema(
getId: _walletInfoGetId,
getLinks: _walletInfoGetLinks,
attach: _walletInfoAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _walletInfoEstimateSize(
### lib/wallets/isar/models/wallet_info_meta.g.dart
@@ -55,7 +55,7 @@ const WalletInfoMetaSchema = CollectionSchema(
getId: _walletInfoMetaGetId,
getLinks: _walletInfoMetaGetLinks,
attach: _walletInfoMetaAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _walletInfoMetaEstimateSize(
### lib/wallets/isar/models/wallet_solana_token_info.g.dart
@@ -71,7 +71,7 @@ const WalletSolanaTokenInfoSchema = CollectionSchema(
getId: _walletSolanaTokenInfoGetId,
getLinks: _walletSolanaTokenInfoGetLinks,
attach: _walletSolanaTokenInfoAttach,
- version: '3.3.0-dev.2',
+ version: '3.3.2',
);
int _walletSolanaTokenInfoEstimateSize(
### lib/wallets/models/tx_data.dart
@@ -105,6 +105,7 @@ class TxData {
final TransactionV2? tempTx;
final bool ignoreCachedBalanceChecks;
+ final bool subtractFeeFromAmount;
// Namecoin Name related
final NameOpState? opNameState;
@@ -150,6 +151,7 @@ class TxData {
this.usedSparkCoins,
this.tempTx,
this.ignoreCachedBalanceChecks = false,
+ this.subtractFeeFromAmount = false,
this.opNameState,
this.sparkNameInfo,
this.vExtraData,
@@ -298,6 +300,7 @@ class TxData {
List<SparkCoin>? usedSparkCoins,
TransactionV2? tempTx,
bool? ignoreCachedBalanceChecks,
+ bool? subtractFeeFromAmount,
NameOpState? opNameState,
({
String additionalInfo,
@@ -346,6 +349,8 @@ class TxData {
tempTx: tempTx ?? this.tempTx,
ignoreCachedBalanceChecks:
ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks,
+ subtractFeeFromAmount:
+ subtractFeeFromAmount ?? this.subtractFeeFromAmount,
opNameState: opNameState ?? this.opNameState,
sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo,
vExtraData: vExtraData ?? this.vExtraData,
@@ -390,6 +395,7 @@ class TxData {
'otherData: $otherData, '
'tempTx: $tempTx, '
'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, '
+ 'subtractFeeFromAmount: $subtractFeeFromAmount, '
'opNameState: $opNameState, '
'sparkNameInfo: $sparkNameInfo, '
'vExtraData: ${vExtraData?.toHex}, '
### lib/wallets/wallet/impl/bitcoin_frost_wallet.dart
@@ -22,6 +22,7 @@ import '../../../services/event_bus/events/global/wallet_sync_status_changed_eve
import '../../../services/event_bus/global_event_bus.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/extensions/extensions.dart';
+import '../../../utilities/flutter_secure_storage_interface.dart';
import '../../../utilities/logger.dart';
import '../../../wl_gen/interfaces/frost_interface.dart';
import '../../crypto_currency/crypto_currency.dart';
@@ -125,6 +126,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
.getUTXOs(walletId)
.filter()
.isBlockedEqualTo(false)
+ .group((q) => q.usedEqualTo(false).or().usedIsNull())
.findAll();
if (utxos.isEmpty) {
@@ -152,7 +154,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
fractionDigits: cryptoCurrency.fractionDigits,
);
final Set<UTXO> utxosToUse = {};
- final Set<UTXO> utxosRemaining = {};
+ final List<UTXO> utxosRemaining = [];
for (int i = 0; i < utxos.length; i++) {
final utxo = utxos[i];
sum += Amount(
@@ -162,7 +164,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
utxosToUse.add(utxo);
if (sum > total) {
if (i + 1 < utxos.length) {
- utxosRemaining.addAll(utxos.sublist(i));
+ utxosRemaining.addAll(utxos.sublist(i + 1));
}
break;
}
@@ -212,7 +214,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
} on FrostInsufficientFundsException catch (_) {
if (utxosRemaining.isNotEmpty) {
// add extra utxo
- final utxo = utxosRemaining.take(1).first;
+ final utxo = utxosRemaining.removeAt(0);
final dData = await getDerivationData(utxo.address);
final publicKey = cryptoCurrency.addressToPubkey(
address: utxo.address!,
@@ -230,7 +232,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
return txData.copyWith(
frostMSConfig: config,
- utxos: utxosToUse.map((e) => StandardInput(e)).toSet(),
+ utxos: inputs.map((e) => StandardInput(e.utxo)).toSet(),
);
} catch (_) {
rethrow;
@@ -837,7 +839,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
const changeChain = 1;
final List<Future<({int index, List<Address> addresses})>>
receiveFutures = [
- _checkGapsLinearly(serializedKeys, receiveChain, secure: true),
+ _checkGapsLinearly(serializedKeys!, receiveChain, secure: true),
];
final List<Future<({int index, List<Address> addresses})>>
changeFutures = [
@@ -870,8 +872,9 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
}
int highestChangeIndexWithHistory = 0;
- // If restoring a wallet that never sent any funds with change, then set changeArray
- // manually. If we didn't do this, it'd store an empty array.
+ // If restoring a wallet that never sent any funds with change, then
+ // set changeArray manually.
+ // If we didn't do this, it'd store an empty array.
for (final tuple in changeResults) {
if (tuple.addresses.isEmpty) {
await checkChangeAddressForTransactions();
@@ -898,7 +901,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
await mainDB.updateOrPutAddresses(addressesToStore);
- await _legacyInsecureScan(serializedKeys);
+ await _legacyInsecureScan(serializedKeys!);
});
GlobalEventBus.instance.fire(
@@ -1111,6 +1114,22 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
// =================== Secure storage ========================================
+ static Future<void> deleteSecureStorage({
+ required String walletId,
+ required SecureStorageInterface secureStorage,
+ }) async {
+ for (final suffix in const [
+ 'serializedFROSTKeys',
+ 'serializedFROSTKeysPrevGen',
+ 'multisigConfig',
+ 'multisigConfigPrevGen',
+ 'multisigIdFROST',
+ 'recoveryStringFROST',
+ ]) {
+ await secureStorage.delete(key: '{$walletId}_$suffix');
+ }
+ }
+
Future<String?> getSerializedKeys() async =>
await secureStorageInterface.read(key: "{$walletId}_serializedFROSTKeys");
@@ -1414,7 +1433,8 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
throw Exception();
} catch (_, s) {
Logging.instance.e(
- "checkReceivingAddressForTransactions called but reuse address flag set: $s",
+ "checkReceivingAddressForTransactions called but reuse address flag"
+ " set: $s",
stackTrace: s,
);
}
@@ -1584,7 +1604,7 @@ class BitcoinFrostWallet<T extends FrostCurrency> extends Wallet<T>
}
Future<Address> _generateAddressSafe({
- required final int chain,
+ required int chain,
required int startingIndex,
}) async {
final serializedKeys = (await getSerializedKeys())!;
### lib/wallets/wallet/impl/epiccash_wallet.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:decimal/decimal.dart';
+import 'package:flutter/foundation.dart';
import 'package:isar_community/isar.dart';
import 'package:mutex/mutex.dart';
import 'package:stack_wallet_backup/generate_password.dart';
@@ -40,6 +41,7 @@ import '../../crypto_currency/crypto_currency.dart';
import '../../models/tx_data.dart';
import '../intermediate/bip39_wallet.dart';
import '../supporting/epiccash_wallet_info_extension.dart';
+import '../supporting/restore_progress.dart';
//
// refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart
@@ -57,7 +59,10 @@ class EpiccashWallet extends Bip39Wallet {
Future<double> get getSyncPercent async {
final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0;
final _chainHeight = await chainHeight;
- final double restorePercent = lastScannedBlock / _chainHeight;
+ final restorePercent = calculateRestoreProgress(
+ scannedHeight: lastScannedBlock,
+ chainHeight: _chainHeight,
+ );
GlobalEventBus.instance.fire(
RefreshPercentChangedEvent(highestPercent, walletId),
);
@@ -493,9 +498,9 @@ class EpiccashWallet extends Bip39Wallet {
int realFee = 0;
try {
- realFee = (Decimal.parse(
- transactionFees.fee.toString(),
- )).toBigInt().toInt();
+ realFee = (Decimal.parse(transactionFees.fee.toString()))
+ .toBigInt()
+ .toInt();
} catch (e, s) {
//todo: come back to this
Logging.instance.e("Error getting fees", error: e, stackTrace: s);
@@ -931,18 +936,22 @@ class EpiccashWallet extends Bip39Wallet {
return await super.init();
}
+ @visibleForTesting
+ bool shouldCheckEpicbox(String receiverAddress) =>
+ !isHttpAddress(receiverAddress);
+
@override
Future<TxData> confirmSend({required TxData txData}) async {
try {
_hackedCheckTorNodePrefs();
- final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
// TODO determine whether it is worth sending change to a change address.
final String receiverAddress = txData.recipients!.first.address;
+ final useEpicbox = shouldCheckEpicbox(receiverAddress);
- if (!receiverAddress.startsWith("http://") ||
- !receiverAddress.startsWith("https://")) {
+ if (useEpicbox) {
+ final epicboxConfig = await getEpicBoxConfig();
final bool isEpicboxConnected = await _testEpicboxServer(epicboxConfig);
if (!isEpicboxConnected) {
throw Exception("Failed to send TX : Unable to reach epicbox server");
@@ -951,8 +960,7 @@ class EpiccashWallet extends Bip39Wallet {
({String commitId, String slateId, String slateJson}) transaction;
- if (receiverAddress.startsWith("http://") ||
- receiverAddress.startsWith("https://")) {
+ if (!useEpicbox) {
final httpResult = await libEpic.txHttpSend(
wallet: _wallet!,
selectionStrategyIsAll: 0,
### lib/wallets/wallet/impl/ethereum_wallet.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:decimal/decimal.dart';
import 'package:ethereum_addresses/ethereum_addresses.dart';
+import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:isar_community/isar.dart';
import 'package:wallet/wallet.dart' as eth_wallet;
@@ -28,8 +29,98 @@ import '../../models/tx_data.dart';
import '../intermediate/bip39_wallet.dart';
import '../wallet_mixin_interfaces/private_key_interface.dart';
+@visibleForTesting
+Future<List<TransactionV2>> findReplacedPendingEthereumTransactions({
+ required String walletId,
+ required Iterable<TransactionV2> transactions,
+ required Future<int> Function() getLatestConfirmedNonce,
+ required Future<web3.TransactionInformation?> Function(String txid)
+ getTransactionByHash,
+ void Function(Object error, StackTrace stackTrace)? onNonceLookupError,
+ void Function(TransactionV2 transaction, Object error, StackTrace stackTrace)?
+ onTransactionLookupError,
+}) async {
+ final candidates = transactions
+ .where(
+ (transaction) =>
+ transaction.walletId == walletId &&
+ transaction.height == null &&
+ transaction.blockHash == null &&
+ transaction.nonce != null &&
+ (transaction.type == TransactionType.outgoing ||
+ transaction.type == TransactionType.sentToSelf) &&
+ (transaction.subType == TransactionSubType.none ||
+ transaction.subType == TransactionSubType.ethToken),
+ )
+ .toList(growable: false);
+
+ if (candidates.isEmpty) {
+ return const [];
+ }
+
+ final int latestConfirmedNonce;
+ try {
+ latestConfirmedNonce = await getLatestConfirmedNonce();
+ } catch (e, s) {
+ onNonceLookupError?.call(e, s);
+ return const [];
+ }
+
+ final replacedTransactions = <TransactionV2>[];
+ for (final transaction in candidates) {
+ if (transaction.nonce! >= latestConfirmedNonce) {
+ continue;
+ }
+
+ try {
+ final originalTransaction = await getTransactionByHash(transaction.txid);
+ if (originalTransaction?.blockHash == null) {
+ replacedTransactions.add(transaction);
+ }
+ } catch (e, s) {
+ onTransactionLookupError?.call(transaction, e, s);
+ }
+ }
+
+ return replacedTransactions;
+}
+
// Eth can not use tor with web3dart
+@visibleForTesting
+({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({
+ required BigInt baseFee,
+ required BigInt priorityFeePerGas,
+ BigInt? customMaxFeePerGas,
+}) {
+ if (customMaxFeePerGas != null && priorityFeePerGas.isNegative) {
+ throw Exception("Max priority fee per gas cannot be negative.");
+ }
+ final maxPriorityFeePerGas = priorityFeePerGas.isNegative
+ ? BigInt.zero
+ : priorityFeePerGas;
+
+ // Presets get 2x base-fee headroom since it can rise 12.5% per block. The
+ // EIP-1559 max fee is the total per-gas cap, so it also includes priority.
+ final maxFeePerGas =
+ customMaxFeePerGas ?? baseFee * BigInt.two + maxPriorityFeePerGas;
+
+ if (maxFeePerGas <= BigInt.zero) {
+ throw Exception("Max fee per gas must be greater than zero.");
+ }
+ if (baseFee > maxFeePerGas) {
+ throw Exception("Max fee per gas is below the current network base fee.");
+ }
+ if (maxPriorityFeePerGas > maxFeePerGas) {
+ throw Exception("Max priority fee per gas exceeds max fee per gas.");
+ }
+
+ return (
+ maxFeePerGas: maxFeePerGas,
+ maxPriorityFeePerGas: maxPriorityFeePerGas,
+ );
+}
+
class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
EthereumWallet(CryptoCurrencyNetwork network) : super(Ethereum(network));
@@ -304,6 +395,7 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
if (response.value!.isEmpty) {
// no new transactions found
+ await deleteReplacedPendingTransactions();
return;
}
@@ -422,6 +514,55 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
txns.add(txn);
}
await mainDB.updateOrPutTransactionV2s(txns);
+ await deleteReplacedPendingTransactions();
+ }
+
+ Future<void> deleteReplacedPendingTransactions() async {
+ final pendingTransactions = await mainDB.isar.transactionV2s
+ .where()
+ .walletIdEqualTo(walletId)
+ .filter()
+ .heightIsNull()
+ .nonceIsNotNull()
+ .findAll();
+
+ web3.Web3Client? client;
+ web3.Web3Client getClient() => client ??= getEthClient();
+ final replacedTransactions = await findReplacedPendingEthereumTransactions(
+ walletId: walletId,
+ transactions: pendingTransactions,
+ getLatestConfirmedNonce: () async => getClient().getTransactionCount(
+ await getMyWeb3Address(),
+ atBlock: const web3.BlockNum.current(),
+ ),
+ getTransactionByHash: (txid) => getClient().getTransactionByHash(txid),
+ onNonceLookupError: (error, stackTrace) {
+ Logging.instance.w(
+ "$runtimeType failed to get the latest confirmed nonce",
+ error: error,
+ stackTrace: stackTrace,
+ );
+ },
+ onTransactionLookupError: (transaction, error, stackTrace) {
+ Logging.instance.w(
+ "$runtimeType failed to look up pending transaction "
+ "${transaction.txid}",
+ error: error,
+ stackTrace: stackTrace,
+ );
+ },
+ );
+ final replacedTransactionIds = replacedTransactions
+ .map((transaction) => transaction.id)
+ .toList(growable: false);
+
+ if (replacedTransactionIds.isEmpty) {
+ return;
+ }
+
+ await mainDB.isar.writeTxn(() async {
+ await mainDB.isar.transactionV2s.deleteAll(replacedTransactionIds);
+ });
}
@override
@@ -440,9 +581,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
({
int nonce,
BigInt chainId,
- BigInt baseFee,
- BigInt maxBaseFee,
- BigInt priorityFee,
+ BigInt maxFeePerGas,
+ BigInt maxPriorityFeePerGas,
})
>
internalSharedPrepareSend({
@@ -469,31 +609,25 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
final feeObject = await fees;
final BigInt baseFee = feeObject.suggestBaseFee;
- // Presets get 2x headroom since base fee can rise 12.5% per block.
- final BigInt maxBaseFee = feeRateType == .custom
- ? txData.ethEIP1559Fee!.maxBaseFeeWei
- : baseFee * BigInt.two;
-
final BigInt rawPriority = switch (feeRateType) {
.fast => feeObject.fast - baseFee,
.average => feeObject.medium - baseFee,
.slow => feeObject.slow - baseFee,
- .custom => txData.ethEIP1559Fee!.priorityFeeWei,
+ .custom => txData.ethEIP1559Fee!.maxPriorityFeePerGasWei,
};
- final BigInt priorityFee = rawPriority.isNegative
- ? BigInt.zero
- : rawPriority;
-
- if (baseFee > maxBaseFee) {
- throw Exception("Max base fee is below the current network base fee.");
- }
+ final feeCaps = resolveEip1559FeeCaps(
+ baseFee: baseFee,
+ priorityFeePerGas: rawPriority,
+ customMaxFeePerGas: feeRateType == .custom
+ ? txData.ethEIP1559Fee!.maxFeePerGasWei
+ : null,
+ );
return (
nonce: nonce,
chainId: chainId,
- baseFee: baseFee,
- maxBaseFee: maxBaseFee,
- priorityFee: priorityFee,
+ maxFeePerGas: feeCaps.maxFeePerGas,
+ maxPriorityFeePerGas: feeCaps.maxPriorityFeePerGas,
);
}
@@ -515,24 +649,26 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
throw Exception("Insufficient balance");
}
+ final gasLimit = txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit;
final tx = web3.Transaction(
to: eth_wallet.EthereumAddress.fromHex(address),
- maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit,
+ maxGas: gasLimit,
value: eth_wallet.EtherAmount.inWei(amount.raw),
nonce: prep.nonce,
maxFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.maxBaseFee,
+ prep.maxFeePerGas,
),
maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.priorityFee,
+ prep.maxPriorityFeePerGas,
),
);
- final feeEstimate = await estimateFeeFor(
- Amount.zero,
- prep.maxBaseFee + prep.priorityFee,
+ final feeEstimate = estimateEthFee(
+ prep.maxFeePerGas,
+ gasLimit,
+ cryptoCurrency.fractionDigits,
);
return txData.copyWith(
### lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart
@@ -35,6 +35,7 @@ import '../../crypto_currency/crypto_currency.dart';
import '../../models/tx_data.dart';
import '../intermediate/bip39_wallet.dart';
import '../supporting/mimblewimblecoin_wallet_info_extension.dart';
+import '../supporting/restore_progress.dart';
class MimblewimblecoinWallet extends Bip39Wallet {
MimblewimblecoinWallet(CryptoCurrencyNetwork network)
@@ -55,7 +56,10 @@ class MimblewimblecoinWallet extends Bip39Wallet {
final int lastScannedBlock =
info.mimblewimblecoinData?.lastScannedBlock ?? 0;
final _chainHeight = await chainHeight;
- final double restorePercent = lastScannedBlock / _chainHeight;
+ final restorePercent = calculateRestoreProgress(
+ scannedHeight: lastScannedBlock,
+ chainHeight: _chainHeight,
+ );
GlobalEventBus.instance.fire(
RefreshPercentChangedEvent(highestPercent, walletId),
);
@@ -622,9 +626,9 @@ class MimblewimblecoinWallet extends Bip39Wallet {
int realFee = 0;
try {
- realFee = (Decimal.parse(
- transactionFees.fee.toString(),
- )).toBigInt().toInt();
+ realFee = (Decimal.parse(transactionFees.fee.toString()))
+ .toBigInt()
+ .toInt();
} catch (e, s) {
//todo: come back to this
debugPrint("$e $s");
@@ -834,7 +838,7 @@ class MimblewimblecoinWallet extends Bip39Wallet {
int _calculateRestoreHeightFrom({required DateTime date}) {
final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000;
- const int mimblewimblecoinFirstBlock = 1565370278;
+ const int mimblewimblecoinFirstBlock = 1573462800;
const double overestimateSecondsPerBlock = 61;
final int chosenSeconds = secondsSinceEpoch - mimblewimblecoinFirstBlock;
final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock;
@@ -1368,8 +1372,7 @@ class MimblewimblecoinWallet extends Bip39Wallet {
}
output = output.copyWith(
addresses: [
- myAddressesSet
- .first, // Must be changed if we ever do more than a single wallet address!!!
+ myAddressesSet.first, // Must be changed if we ever do more than a single wallet address!!!
],
walletOwns: true,
);
### lib/wallets/wallet/impl/solana_wallet.dart
@@ -472,19 +472,17 @@ class SolanaWallet extends Bip39Wallet<Solana> {
@override
Future<void> updateNode() async {
_solNode =
- NodeService(
- secureStorageInterface: secureStorageInterface,
- ).getPrimaryNodeFor(currency: info.coin) ??
+ NodeService(secureStorageInterface: secureStorageInterface)
+ .getPrimaryNodeFor(currency: info.coin) ??
info.coin.defaultNode(isPrimary: true);
await refresh();
}
@override
NodeModel getCurrentNode() {
_solNode ??=
- NodeService(
- secureStorageInterface: secureStorageInterface,
- ).getPrimaryNodeFor(currency: info.coin) ??
+ NodeService(secureStorageInterface: secureStorageInterface)
+ .getPrimaryNodeFor(currency: info.coin) ??
info.coin.defaultNode(isPrimary: true);
return _solNode!;
@@ -713,11 +711,11 @@ class SolanaWallet extends Bip39Wallet<Solana> {
// static helper function for building a sol rpc client
static RpcClient createRpcClient(
- final String host,
- final int port,
- final bool useSSL,
- final Prefs prefs,
- final TorService torService,
+ String host,
+ int port,
+ bool useSSL,
+ Prefs prefs,
+ TorService torService,
) {
HttpClient? httpClient;
### lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart
@@ -240,25 +240,28 @@ class EthTokenWallet extends Wallet {
throw Exception("Insufficient balance");
}
+ final gasLimit =
+ txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit;
final tx = web3dart.Transaction.callContract(
contract: _deployedContract,
function: _sendFunction,
parameters: [eth_wallet.EthereumAddress.fromHex(address), amount.raw],
- maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit,
+ maxGas: gasLimit,
nonce: prep.nonce,
maxFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.maxBaseFee,
+ prep.maxFeePerGas,
),
maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.priorityFee,
+ prep.maxPriorityFeePerGas,
),
);
- final feeEstimate = await estimateFeeFor(
- Amount.zero,
- prep.maxBaseFee + prep.priorityFee,
+ final feeEstimate = ethWallet.estimateEthFee(
+ prep.maxFeePerGas,
+ gasLimit,
+ cryptoCurrency.fractionDigits,
);
return txData.copyWith(
fee: feeEstimate,
@@ -385,6 +388,7 @@ class EthTokenWallet extends Wallet {
// no need to continue if no transactions found
if (response.value!.isEmpty) {
+ await ethWallet.deleteReplacedPendingTransactions();
return;
}
@@ -506,6 +510,7 @@ class EthTokenWallet extends Wallet {
}
}
await mainDB.updateOrPutTransactionV2s(txns);
+ await ethWallet.deleteReplacedPendingTransactions();
} catch (e, s) {
Logging.instance.w(
"$runtimeType wallet failed to update transactions: ",
### lib/wallets/wallet/impl/xelis_wallet.dart
@@ -22,6 +22,7 @@ import '../../../wl_gen/interfaces/lib_xelis_interface.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../../models/tx_data.dart';
import '../intermediate/lib_xelis_wallet.dart';
+import '../intermediate/xelis_event_batcher.dart';
import '../wallet.dart';
class XelisWallet extends LibXelisWallet {
@@ -200,7 +201,7 @@ class XelisWallet extends LibXelisWallet {
@override
Future<void> recover({required bool isRescan}) async {
if (isRescan) {
- await refreshMutex.protect(() async {
+ await runXelisRescan(() async {
await mainDB.deleteWalletBlockchainData(walletId);
await updateTransactions(isRescan: true, topoheight: 0);
});
@@ -229,18 +230,19 @@ class XelisWallet extends LibXelisWallet {
Future<bool> pingCheck() async {
try {
await libXelis.getDaemonInfo(wallet!);
- await handleOnline();
return true;
} catch (_) {
- await handleOffline();
return false;
}
}
final _balanceUpdateMutex = Mutex();
@override
- Future<void> updateBalance({int? newBalance}) async {
+ Future<void> updateBalance({
+ int? newBalance,
+ bool rethrowErrors = false,
+ }) async {
await _balanceUpdateMutex.protect(() async {
try {
if (await libXelis.hasXelisBalance(wallet!)) {
@@ -273,6 +275,9 @@ class XelisWallet extends LibXelisWallet {
error: e,
stackTrace: s,
);
+ if (rethrowErrors) {
+ rethrow;
+ }
}
});
}
@@ -288,7 +293,10 @@ class XelisWallet extends LibXelisWallet {
}
@override
- Future<void> updateChainHeight({int? topoheight}) async {
+ Future<void> updateChainHeight({
+ int? topoheight,
+ bool rethrowErrors = false,
+ }) async {
try {
final height = topoheight ?? await _fetchChainHeight();
@@ -302,17 +310,16 @@ class XelisWallet extends LibXelisWallet {
error: e,
stackTrace: s,
);
+ if (rethrowErrors) {
+ rethrow;
+ }
}
}
@override
Future<void> updateNode() async {
try {
- final bool online = await libXelis.isOnline(wallet!);
- if (online == true) {
- await libXelis.offlineMode(wallet!);
- }
- await super.connect();
+ await connect(disconnectFirst: true);
} catch (e, s) {
Logging.instance.e(
"Error rethrown from $runtimeType updateNode()",
@@ -733,8 +740,7 @@ class XelisWallet extends LibXelisWallet {
? recipients
: [
TxRecipient(
- address:
- 'xel:xz9574c80c4xegnvurazpmxhw5dlg2n0g9qm60uwgt75uqyx3pcsqzzra9m',
+ address: 'xel:xz9574c80c4xegnvurazpmxhw5dlg2n0g9qm60uwgt75uqyx3pcsqzzra9m',
amount: amount,
isChange: false,
addressType: AddressType.xelis,
@@ -897,75 +903,60 @@ class XelisWallet extends LibXelisWallet {
}
@override
- Future<void> handleNewTopoHeight(int height) async {
- await info.updateCachedChainHeight(newHeight: height, isar: mainDB.isar);
- }
+ Future<void> handleNewTopoHeight(int _) async =>
+ eventBatcher.queueTopoheightChanged();
@override
- Future<void> handleNewTransaction(TransactionEntryWrapper tx) async {
- try {
- final txList = [tx];
- final newTxIds = await updateTransactions(
- isRescan: false,
- objTransactions: txList,
- );
-
- await updateBalance();
+ Future<void> handleNewTransaction(TransactionEntryWrapper tx) async =>
+ eventBatcher.queueTransaction(tx);
- // Logging.instance.log(
- // "New transaction processed: ${newTxIds.first}",
- // level: LogLevel.Info,
- // );
- } catch (e, s) {
- Logging.instance.e(
- "Error in $runtimeType handleNewTransaction($tx)",
- error: e,
- stackTrace: s,
- );
+ @override
+ Future<void> handleBalanceChanged(BalanceChanged event) async {
+ if (event.asset == libXelis.xelisAsset) {
+ eventBatcher.queueBalanceChanged();
}
}
@override
- Future<void> handleBalanceChanged(BalanceChanged event) async {
+ Future<void> applyXelisEventBatch(
+ XelisEventBatch<TransactionEntryWrapper> batch,
+ ) async {
try {
- final asset = event.asset;
- if (asset == libXelis.xelisAsset) {
- await updateBalance(newBalance: event.balance);
+ if (batch.topoheightChanged) {
+ await updateChainHeight(rethrowErrors: true);
}
- // TODO: Update asset balances if needed
+ if (batch.transactions.isNotEmpty) {
+ await updateTransactions(
+ isRescan: false,
+ objTransactions: batch.transactions,
+ );
+ }
+
+ if (batch.balanceChanged || batch.transactions.isNotEmpty) {
+ await updateBalance(rethrowErrors: true);
+ }
} catch (e, s) {
Logging.instance.e(
- "Error in $runtimeType handleBalanceChanged($event)",
+ "Error in $runtimeType applyXelisEventBatch()",
error: e,
stackTrace: s,
);
+ unawaited(refresh());
}
}
@override
Future<void> handleRescan(int startTopoheight) async {
- await refreshMutex.protect(() async {
+ await runXelisRescan(() async {
await mainDB.deleteWalletBlockchainData(walletId);
await updateTransactions(isRescan: true, topoheight: startTopoheight);
await updateBalance();
});
}
@override
- Future<void> handleOnline() async {
- await updateChainHeight();
- await updateBalance();
- await updateTransactions();
- GlobalEventBus.instance.fire(
- WalletSyncStatusChangedEvent(
- WalletSyncStatus.synced,
- walletId,
- info.coin,
- ),
- );
- unawaited(refresh());
- }
+ Future<void> handleOnline() => runXelisSyncEvent();
@override
Future<void> handleOffline() async {
@@ -979,18 +970,7 @@ class XelisWallet extends LibXelisWallet {
}
@override
- Future<void> handleHistorySynced(int topoheight) async {
- await updateChainHeight();
- await updateBalance();
- await updateTransactions();
- GlobalEventBus.instance.fire(
- WalletSyncStatusChangedEvent(
- WalletSyncStatus.synced,
- walletId,
- info.coin,
- ),
- );
- }
+ Future<void> handleHistorySynced(int _) => runXelisSyncEvent();
@override
Future<void> handleNewAsset(NewAsset asset) async {
@@ -1000,30 +980,59 @@ class XelisWallet extends LibXelisWallet {
}
@override
- Future<void> refresh({int? topoheight}) async {
- await refreshMutex.protect(() async {
- try {
- final bool online = await libXelis.isOnline(wallet!);
- if (online == true) {
- await updateChainHeight(topoheight: topoheight);
- await updateBalance();
- await updateTransactions();
- } else {
+ Future<void> performXelisRefresh() async {
+ try {
+ final bool online = await libXelis.isOnline(wallet!);
+ if (online == true) {
+ if (!doNotFireRefreshEvents) {
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
- WalletSyncStatus.unableToSync,
+ WalletSyncStatus.syncing,
walletId,
info.coin,
),
);
}
- } catch (e, s) {
- Logging.instance.e(
- "Error in $runtimeType refresh()",
- error: e,
- stackTrace: s,
+
+ await updateChainHeight(rethrowErrors: true);
+ await updateBalance(rethrowErrors: true);
+ await updateTransactions();
+
+ if (!doNotFireRefreshEvents) {
+ GlobalEventBus.instance.fire(
+ WalletSyncStatusChangedEvent(
+ WalletSyncStatus.synced,
+ walletId,
+ info.coin,
+ ),
+ );
+ }
+ ensurePeriodicRefreshTimer();
+ } else if (!doNotFireRefreshEvents) {
+ GlobalEventBus.instance.fire(
+ WalletSyncStatusChangedEvent(
+ WalletSyncStatus.unableToSync,
+ walletId,
+ info.coin,
+ ),
);
}
- });
+ } catch (e, s) {
+ if (!doNotFireRefreshEvents) {
+ GlobalEventBus.instance.fire(
+ WalletSyncStatusChangedEvent(
+ WalletSyncStatus.unableToSync,
+ walletId,
+ info.coin,
+ ),
+ );
+ }
+ Logging.instance.e(
+ "Error in $runtimeType performXelisRefresh()",
+ error: e,
+ stackTrace: s,
+ );
+ rethrow;
+ }
}
}
### lib/wallets/wallet/intermediate/lib_xelis_wallet.dart
@@ -12,6 +12,8 @@ import '../../../wl_gen/interfaces/lib_xelis_interface.dart';
import '../../crypto_currency/intermediate/electrum_currency.dart';
import '../wallet_mixin_interfaces/mnemonic_interface.dart';
import 'external_wallet.dart';
+import 'xelis_event_batcher.dart';
+import 'xelis_operation_coordinator.dart';
abstract class LibXelisWallet<T extends ElectrumCurrency>
extends ExternalWallet<T>
@@ -38,6 +40,18 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
Timer? timer;
StreamSubscription<void>? _eventSubscription;
+ late final XelisOperationCoordinator _operationCoordinator =
+ XelisOperationCoordinator(refreshMutex);
+
+ static const _eventFlushInterval = Duration(milliseconds: 500);
+
+ @protected
+ late final XelisEventBatcher<TransactionEntryWrapper> eventBatcher =
+ XelisEventBatcher(
+ flushInterval: _eventFlushInterval,
+ flush: (batch) =>
+ runXelisEventUpdate(() => applyXelisEventBatch(batch)),
+ );
Future<String> getPrecomputedTablesPath() async {
if (kIsWeb) {
@@ -88,31 +102,70 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
Future<void> handleHistorySynced(int topoheight) async {}
Future<void> handleNewAsset(NewAsset asset) async {}
- @override
- Future<void> refresh({int? topoheight});
-
- Future<void> connect() async {
- final node = getCurrentNode();
- try {
- checkInitialized();
- _eventSubscription = libXelis.eventsStream(wallet!).listen(handleEvent);
-
- Logging.instance.i("Connecting to node: ${node.host}:${node.port}");
- await libXelis.onlineMode(
- wallet!,
- daemonAddress: "${node.host}:${node.port}",
- );
- await super.refresh();
- } catch (e, s) {
- Logging.instance.e(
- "rethrowing error connecting to node: $node",
- error: e,
- stackTrace: s,
- );
- rethrow;
- }
+ @protected
+ Future<void> performXelisRefresh();
+
+ @protected
+ Future<void> applyXelisEventBatch(
+ XelisEventBatch<TransactionEntryWrapper> batch,
+ );
+
+ @protected
+ Future<void> runXelisRescan(Future<void> Function() operation) {
+ eventBatcher.reset();
+ return _operationCoordinator.rescan(operation);
}
+ @protected
+ Future<void> runXelisEventUpdate(Future<void> Function() operation) =>
+ _operationCoordinator.processEvent(operation);
+
+ @protected
+ Future<void> runXelisSyncEvent() =>
+ _operationCoordinator.processSyncEvent(performXelisRefresh);
+
+ // Intentionally swallow logged errors because refresh is often unawaited.
+ @override
+ Future<void> refresh() =>
+ _operationCoordinator.refresh(performXelisRefresh).catchError((_) {});
+
+ Future<void> connect({bool disconnectFirst = false}) =>
+ _operationCoordinator.connect(() async {
+ final node = getCurrentNode();
+ try {
+ checkInitialized();
+
+ final wasOnline = await libXelis.isOnline(wallet!);
+ await _eventSubscription?.cancel();
+ _eventSubscription = null;
+
+ if (wasOnline && disconnectFirst) {
+ await libXelis.offlineMode(wallet!);
+ }
+
+ _eventSubscription = libXelis.eventsStream(wallet!).listen((event) {
+ unawaited(handleEvent(event));
+ });
+
+ if (!wasOnline || disconnectFirst) {
+ Logging.instance.i("Connecting to node: ${node.host}:${node.port}");
+ await libXelis.onlineMode(
+ wallet!,
+ daemonAddress: "${node.host}:${node.port}",
+ );
+ }
+
+ await performXelisRefresh();
+ } catch (e, s) {
+ Logging.instance.e(
+ "rethrowing error connecting to node: $node",
+ error: e,
+ stackTrace: s,
+ );
+ rethrow;
+ }
+ }, joinExisting: !disconnectFirst);
+
List<FilterOperation> get standardReceivingAddressFilters => [
FilterCondition.equalTo(property: r"type", value: info.mainAddressType),
const FilterCondition.equalTo(
@@ -139,45 +192,22 @@ abstract class LibXelisWallet<T extends ElectrumCurrency>
}
@override
- Future<void> open() async {
- while (exitInProgress) {
- await Future<void>.delayed(const Duration(milliseconds: 500));
- }
+ Future<void> open() => connect();
- try {
- await connect();
- } catch (e) {
- // Logging.instance.log(
- // "Failed to start sync: $e",
- // level: LogLevel.Error,
- // );
- rethrow;
- }
- unawaited(refresh());
- }
+ @override
+ Future<void> exit() => _operationCoordinator.exit(() async {
+ timer?.cancel();
+ timer = null;
- bool exitInProgress = false;
+ eventBatcher.reset();
+ await _eventSubscription?.cancel();
+ _eventSubscription = null;
- @override
- Future<void> exit() async {
- exitInProgress = true;
- try {
- await refreshMutex.protect(() async {
- timer?.cancel();
- timer = null;
-
- await _eventSubscription?.cancel();
- _eventSubscription = null;
-
- if (wallet != null && await libXelis.isOnline(wallet!)) {
- await libXelis.offlineMode(wallet!);
- }
- await super.exit();
- });
- } finally {
- exitInProgress = false;
+ if (wallet != null && await libXelis.isOnline(wallet!)) {
+ await libXelis.offlineMode(wallet!);
}
- }
+ await super.exit();
+ });
void invalidSeedLengthCheck(int length) {
if (!(length == 25)) {
### lib/wallets/wallet/intermediate/xelis_event_batcher.dart
@@ -0,0 +1,84 @@
+import 'dart:async';
+
+final class XelisEventBatch<T> {
+ const XelisEventBatch({
+ required this.transactions,
+ required this.topoheightChanged,
+ required this.balanceChanged,
+ });
+
+ final List<T> transactions;
+ final bool topoheightChanged;
+ final bool balanceChanged;
+
+ bool get isEmpty =>
+ transactions.isEmpty && !topoheightChanged && !balanceChanged;
+}
+
+final class XelisEventBatcher<T> {
+ XelisEventBatcher({required this.flushInterval, required this.flush});
+
+ final Duration flushInterval;
+ final Future<void> Function(XelisEventBatch<T> batch) flush;
+
+ final List<T> _transactions = [];
+ bool _topoheightChanged = false;
+ bool _balanceChanged = false;
+ bool _isFlushing = false;
+ Timer? _flushTimer;
+
+ void queueTransaction(T transaction) {
+ _transactions.add(transaction);
+ _scheduleFlush();
+ }
+
+ void queueTopoheightChanged() {
+ _topoheightChanged = true;
+ _scheduleFlush();
+ }
+
+ void queueBalanceChanged() {
+ _balanceChanged = true;
+ _scheduleFlush();
+ }
+
+ void reset() {
+ _flushTimer?.cancel();
+ _flushTimer = null;
+ _transactions.clear();
+ _topoheightChanged = false;
+ _balanceChanged = false;
+ }
+
+ void _scheduleFlush() {
+ if (!_isFlushing) {
+ _flushTimer ??= Timer(flushInterval, () => unawaited(_flushNow()));
+ }
+ }
+
+ Future<void> _flushNow() async {
+ _flushTimer = null;
+ final batch = XelisEventBatch<T>(
+ transactions: List.of(_transactions),
+ topoheightChanged: _topoheightChanged,
+ balanceChanged: _balanceChanged,
+ );
+ _transactions.clear();
+ _topoheightChanged = false;
+ _balanceChanged = false;
+
+ if (batch.isEmpty) {
+ return;
+ }
+
+ _isFlushing = true;
+ try {
+ await flush(batch);
+ } finally {
+ _isFlushing = false;
+ if (_transactions.isNotEmpty || _topoheightChanged || _balanceChanged) {
+ _scheduleFlush();
+ }
+ }
+ }
+}
### lib/wallets/wallet/intermediate/xelis_operation_coordinator.dart
@@ -0,0 +1,145 @@
+import 'package:mutex/mutex.dart';
+
+enum XelisOperation {
+ idle,
+ connecting,
+ refreshing,
+ rescanning,
+ processingEvent,
+ exiting,
+}
+
+final class XelisOperationCoordinator {
+ XelisOperationCoordinator(this._mutex);
+
+ final Mutex _mutex;
+
+ Future<void>? _latestSyncFuture;
+ Future<void>? _pendingExitFuture;
+ ({XelisOperation operation, Future<void> future})? _latestLifecycle;
+
+ XelisOperation _activeOperation = XelisOperation.idle;
+
+ XelisOperation get activeOperation => _activeOperation;
+
+ Future<void> connect(
+ Future<void> Function() operation, {
+ bool joinExisting = true,
+ }) {
+ final latestLifecycle = _latestLifecycle;
+ if (joinExisting &&
+ latestLifecycle?.operation == XelisOperation.connecting) {
+ return latestLifecycle!.future;
+ }
+
+ late final Future<void> future;
+ future = _run(XelisOperation.connecting, operation).whenComplete(() {
+ _clearLatestLifecycle(future);
+ _clearLatestSync(future);
+ });
+
+ _latestLifecycle = (operation: XelisOperation.connecting, future: future);
+ _latestSyncFuture = future;
+ return future;
+ }
+
+ Future<void> refresh(Future<void> Function() operation) {
+ final latestSyncFuture = _latestSyncFuture;
+ if (latestSyncFuture != null) {
+ return latestSyncFuture;
+ }
+
+ if (_pendingExitFuture != null) {
+ return Future<void>.value();
+ }
+
+ return _scheduleSync(XelisOperation.refreshing, operation);
+ }
+
+ Future<void> rescan(Future<void> Function() operation) {
+ if (_pendingExitFuture != null) {
+ return Future<void>.error(
+ StateError('Cannot rescan a Xelis wallet while it is exiting'),
+ );
+ }
+
+ return _scheduleSync(XelisOperation.rescanning, operation);
+ }
+
+ Future<void> processEvent(Future<void> Function() operation) {
+ if (_pendingExitFuture != null) {
+ return Future<void>.value();
+ }
+
+ return _run(XelisOperation.processingEvent, operation);
+ }
+
+ Future<void> processSyncEvent(Future<void> Function() operation) {
+ if (_pendingExitFuture != null) {
+ return Future<void>.value();
+ }
+
+ return refresh(operation);
+ }
+
+ Future<void> exit(Future<void> Function() operation) {
+ final latestLifecycle = _latestLifecycle;
+ if (latestLifecycle?.operation == XelisOperation.exiting) {
+ return latestLifecycle!.future;
+ }
+
+ _latestSyncFuture = null;
+
+ late final Future<void> future;
+ future = _run(XelisOperation.exiting, operation).whenComplete(() {
+ if (identical(_pendingExitFuture, future)) {
+ _pendingExitFuture = null;
+ }
+ _clearLatestLifecycle(future);
+ });
+
+ _latestLifecycle = (operation: XelisOperation.exiting, future: future);
+ _pendingExitFuture = future;
+ return future;
+ }
+
+ Future<void> _scheduleSync(
+ XelisOperation operationType,
+ Future<void> Function() operation,
+ ) {
+ late final Future<void> future;
+ future = _run(operationType, operation).whenComplete(() {
+ _clearLatestSync(future);
+ });
+
+ _latestSyncFuture = future;
+ return future;
+ }
+
+ Future<void> _run(
+ XelisOperation operationType,
+ Future<void> Function() operation,
+ ) {
+ return _mutex.protect(() async {
+ assert(_activeOperation == XelisOperation.idle);
+ _activeOperation = operationType;
+ try {
+ await operation();
+ } finally {
+ _activeOperation = XelisOperation.idle;
+ }
+ });
+ }
+
+ void _clearLatestSync(Future<void> completedFuture) {
+ if (identical(_latestSyncFuture, completedFuture)) {
+ _latestSyncFuture = null;
+ }
+ }
+
+ void _clearLatestLifecycle(Future<void> completedFuture) {
+ if (identical(_latestLifecycle?.future, completedFuture)) {
+ _latestLifecycle = null;
+ }
+ }
+}
### lib/wallets/wallet/supporting/restore_progress.dart
@@ -0,0 +1,4 @@
+double calculateRestoreProgress({
+ required int scannedHeight,
+ required int chainHeight,
+}) => chainHeight <= 0 ? 0.0 : scannedHeight / chainHeight;
### lib/wallets/wallet/wallet.dart
@@ -556,21 +556,7 @@ abstract class Wallet<T extends CryptoCurrency> {
),
);
}
- if (shouldAutoSync) {
- _periodicRefreshTimer ??= Timer.periodic(const Duration(seconds: 150), (
- timer,
- ) async {
- // chain height check currently broken
- // if ((await chainHeight) != (await storedChainHeight)) {
-
- // TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call
- // if (await refreshIfThereIsNewData()) {
- unawaited(refresh());
-
- // }
- // }
- });
- }
+ ensurePeriodicRefreshTimer();
},
onError: (Object e, StackTrace s) {
if (!doNotFireRefreshEvents) {
@@ -602,6 +588,16 @@ abstract class Wallet<T extends CryptoCurrency> {
return future;
}
+ @protected
+ void ensurePeriodicRefreshTimer() {
+ if (shouldAutoSync) {
+ _periodicRefreshTimer ??= Timer.periodic(
+ const Duration(seconds: 150),
+ (_) => unawaited(refresh()),
+ );
+ }
+ }
+
void _fireRefreshPercentChange(double percent) {
if (this is ElectrumXInterface) {
(this as ElectrumXInterface?)?.refreshingPercent = percent;
### lib/wallets/wallet/wallet_mixin_interfaces/electrum_fee_planner.dart
@@ -0,0 +1,100 @@
+enum ElectrumFeeMode { fixedAmount, subtractFeeFromAmount, sweep }
+
+final class ElectrumFeeInsufficientFunds implements Exception {
+ /// The fee the rejected transaction needed to pay. Callers retrying with
+ /// more funds should select inputs covering at least the recipient amount
+ /// plus this fee.
+ final BigInt requiredFee;
+
+ const ElectrumFeeInsufficientFunds({required this.requiredFee});
+}
+
+typedef ElectrumFeeTransactionBuilder<T> =
+ Future<({T transaction, int vSize})> Function({
+ required BigInt recipientAmount,
+ BigInt? changeAmount,
+ });
+
+final class ElectrumFeeResult<T> {
+ final T transaction;
+ final BigInt fee;
+
+ const ElectrumFeeResult({required this.transaction, required this.fee});
+}
+
+/// The fee paid is always at least [minimumFeeAmount] (when non-null), the
+/// rate-based fee for the measured vSize, and one sat per vByte, whichever is
+/// greatest. [minimumFeeAmount] is a floor, not an exact override.
+Future<ElectrumFeeResult<T>> planElectrumFee<T>({
+ required ElectrumFeeMode mode,
+ required BigInt inputTotal,
+ required BigInt recipientAmount,
+ required BigInt dustLimit,
+ required int? satsPerVByte,
+ required BigInt feeRatePerKB,
+ required BigInt? minimumFeeAmount,
+ required ElectrumFeeTransactionBuilder<T> build,
+}) async {
+ if (mode != ElectrumFeeMode.sweep && recipientAmount < dustLimit) {
+ throw Exception(
+ "Recipient amount ($recipientAmount) is below dust limit ($dustLimit)",
+ );
+ }
+
+ BigInt requiredFeeFor(int vSize) {
+ final BigInt rateFee;
+ if (satsPerVByte != null) {
+ rateFee = BigInt.from(satsPerVByte * vSize);
+ } else {
+ final kb = BigInt.from(1000);
+ rateFee = (feeRatePerKB * BigInt.from(vSize) + kb - BigInt.one) ~/ kb;
+ }
+
+ final vSizeFloor = BigInt.from(vSize);
+ final minimumFloor = minimumFeeAmount ?? BigInt.zero;
+ final feeFloor = rateFee > minimumFloor ? rateFee : minimumFloor;
+ return feeFloor > vSizeFloor ? feeFloor : vSizeFloor;
+ }
+
+ final selectedSurplus = inputTotal - recipientAmount;
+ final subDustSurplus =
+ mode == .subtractFeeFromAmount &&
+ selectedSurplus > BigInt.zero &&
+ selectedSurplus < dustLimit
+ ? selectedSurplus
+ : BigInt.zero;
+ BigInt fee = -subDustSurplus;
+ while (true) {
+ final amountToSend = switch (mode) {
+ .fixedAmount => recipientAmount,
+ .subtractFeeFromAmount => recipientAmount - fee,
+ .sweep => inputTotal - fee,
+ };
+ if (amountToSend < dustLimit) {
+ throw Exception("Estimated fee ($fee sats) leaves no spendable amount!");
+ }
+
+ final possibleChange = switch (mode) {
+ .fixedAmount => inputTotal - recipientAmount - fee,
+ .subtractFeeFromAmount => inputTotal - recipientAmount,
+ .sweep => null,
+ };
+ final changeAmount = possibleChange != null && possibleChange >= dustLimit
+ ? possibleChange
+ : null;
+
+ final built = await build(
+ recipientAmount: amountToSend,
+ changeAmount: changeAmount,
+ );
+ final feePaid = inputTotal - amountToSend - (changeAmount ?? BigInt.zero);
+ final requiredFee = requiredFeeFor(built.vSize);
+ if (feePaid >= requiredFee) {
+ return ElectrumFeeResult(transaction: built.transaction, fee: feePaid);
+ }
+ if (mode == ElectrumFeeMode.fixedAmount && possibleChange! < dustLimit) {
+ throw ElectrumFeeInsufficientFunds(requiredFee: requiredFee);
+ }
+ fee += requiredFee - feePaid;
+ }
+}
### lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -34,12 +34,42 @@ import '../impl/firo_wallet.dart';
import '../impl/peercoin_wallet.dart';
import '../intermediate/bip39_hd_wallet.dart';
import 'cpfp_interface.dart';
+import 'electrum_fee_planner.dart';
+import 'mweb_fee_utils.dart';
import 'mweb_interface.dart';
import 'paynym_interface.dart';
import 'rbf_interface.dart';
import 'sign_verify_interface.dart';
import 'view_only_option_interface.dart';
+@visibleForTesting
+bool isMwebPegoutOutput(List<dynamic> outputs, int vout) {
+ if (vout <= 0) {
+ return false;
+ }
+
+ for (final output in outputs) {
+ if (output is! Map || output["n"] != 0) {
+ continue;
+ }
+
+ final scriptPubKey = output["scriptPubKey"];
+ if (scriptPubKey is! Map) {
+ return false;
+ }
+
+ if (scriptPubKey["type"] == "witness_mweb_hogaddr") {
+ return true;
+ }
+
+ final scriptHex = scriptPubKey["hex"];
+ return scriptHex is String &&
+ RegExp(r'^5820[0-9a-fA-F]{64}$').hasMatch(scriptHex);
+ }
+
+ return false;
+}
+
mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T>
implements ViewOnlyOptionInterface<T>, SignVerifyInterface {
@@ -123,14 +153,18 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
required bool coinControl,
required bool isSendAll,
required bool isSendAllCoinControlUtxos,
- int additionalOutputs = 0,
List<BaseInput>? utxos,
- BigInt? overrideFeeAmount,
+ BigInt? minimumFeeAmount,
}) async {
Logging.instance.d("Starting coinSelection ----------");
// TODO: multiple recipients one day
assert(txData.recipients!.length == 1);
+ if (txData.recipients!.length != 1) {
+ throw Exception(
+ "Transactions with more than one recipient are not supported",
+ );
+ }
if (coinControl && utxos == null) {
throw Exception("Coin control used where utxos is null!");
@@ -199,7 +233,8 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
throw Exception("Insufficient balance");
} else if (spendableSatoshiValue == satoshiAmountToSend &&
!isSendAll &&
- !isSendAllCoinControlUtxos) {
+ !isSendAllCoinControlUtxos &&
+ !txData.subtractFeeFromAmount) {
throw Exception("Insufficient balance to pay transaction fee");
}
@@ -224,411 +259,193 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend");
// Use coinlib CoinSelection algorithms except for
- // "coinControl", "SendAll", "MWEB", "overrideFeeAmount",
+ // "coinControl", "SendAll", "MWEB", "minimumFeeAmount",
+ // and "subtractFeeFromAmount"
// because they do not need a selection or
// do not meet the requirements for the algorithms
final bool useOptimalSelection =
!coinControl &&
!isSendAll &&
!isSendAllCoinControlUtxos &&
- overrideFeeAmount == null &&
+ !txData.subtractFeeFromAmount &&
+ minimumFeeAmount == null &&
txData.type != TxType.mweb &&
txData.type != TxType.mwebPegOut &&
txData.type != TxType.mwebPegIn;
if (useOptimalSelection) {
- return await _optimalCoinSelection(
- txData: txData,
- spendableOutputs: spendableOutputs.whereType<StandardInput>().toList(),
- recipientAddress: recipientAddress,
- satoshiAmountToSend: satoshiAmountToSend,
- satsPerVByte: satsPerVByte,
- feeRatePerKB: selectedTxFeeRate,
- changeAddress: await changeAddress(),
- );
+ try {
+ return await _optimalCoinSelection(
+ txData: txData,
+ spendableOutputs: spendableOutputs
+ .whereType<StandardInput>()
+ .toList(),
+ recipientAddress: recipientAddress,
+ satoshiAmountToSend: satoshiAmountToSend,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: selectedTxFeeRate,
+ changeAddress: await changeAddress(),
+ );
+ } on ElectrumFeeInsufficientFunds catch (e) {
+ Logging.instance.w(
+ "Optimal coin selection could not cover the measured transaction "
+ "fee (${e.requiredFee} sats). Falling back to previous/old input "
+ "selection.",
+ );
+ }
}
BigInt satoshisBeingUsed = BigInt.zero;
int inputsBeingConsumed = 0;
final List<BaseInput> utxoObjectsToUse = [];
+ final List<BaseInput> inputsWithKeys = [];
- if (!coinControl) {
- for (
- var i = 0;
- satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length;
- i++
- ) {
- utxoObjectsToUse.add(spendableOutputs[i]);
- satoshisBeingUsed += spendableOutputs[i].value;
- inputsBeingConsumed += 1;
- }
- for (
- int i = 0;
- i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length;
- i++
- ) {
- utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]);
- satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value;
- inputsBeingConsumed += 1;
+ /// Consume spendable outputs until [target] is covered (all of them when
+ /// using coin control), gathering signing data for newly added inputs.
+ Future<void> consumeInputsFor(BigInt target) async {
+ final start = inputsBeingConsumed;
+ if (coinControl) {
+ satoshisBeingUsed = spendableSatoshiValue;
+ utxoObjectsToUse.addAll(spendableOutputs);
+ inputsBeingConsumed = spendableOutputs.length;
+ } else {
+ while (satoshisBeingUsed < target &&
+ inputsBeingConsumed < spendableOutputs.length) {
+ utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]);
+ satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value;
+ inputsBeingConsumed += 1;
+ }
}
- } else {
- satoshisBeingUsed = spendableSatoshiValue;
- utxoObjectsToUse.addAll(spendableOutputs);
- inputsBeingConsumed = spendableOutputs.length;
- }
-
- Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
- Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed");
- Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse');
+ inputsWithKeys.addAll(
+ await addSigningKeys(utxoObjectsToUse.sublist(start)),
+ );
- // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray
- final List<String> recipientsArray = [recipientAddress];
- final List<BigInt> recipientsAmtArray = [satoshiAmountToSend];
+ Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
+ Logging.instance.d("inputsBeingConsumed: $inputsBeingConsumed");
+ Logging.instance.d('utxoObjectsToUse: $utxoObjectsToUse');
+ }
- // gather required signing data
- final inputsWithKeys = await addSigningKeys(utxoObjectsToUse);
+ await consumeInputsFor(satoshiAmountToSend);
if (isSendAll || isSendAllCoinControlUtxos) {
- if ((overrideFeeAmount ?? BigInt.zero) + satoshiAmountToSend !=
+ if ((minimumFeeAmount ?? BigInt.zero) + satoshiAmountToSend !=
satoshisBeingUsed) {
Logging.instance.d("txData.type: ${txData.type}");
Logging.instance.d("isSendAll: $isSendAll");
Logging.instance.d(
"isSendAllCoinControlUtxos: $isSendAllCoinControlUtxos",
);
- Logging.instance.d("overrideFeeAmount: $overrideFeeAmount");
+ Logging.instance.d("minimumFeeAmount: $minimumFeeAmount");
Logging.instance.d("satoshiAmountToSend: $satoshiAmountToSend");
Logging.instance.d("satoshisBeingUsed: $satoshisBeingUsed");
// hack check
if (!(txData.type == TxType.mwebPegIn ||
- (txData.type.isMweb() && overrideFeeAmount != null))) {
+ (txData.type.isMweb() && minimumFeeAmount != null))) {
throw Exception(
"Something happened that should never actually happen. "
"Please report this error to the developers.",
);
}
}
- return await _sendAllBuilder(
+ return await _buildTransactionPayingFee(
txData: txData,
- recipientAddress: recipientAddress,
- satoshisBeingUsed: satoshisBeingUsed,
inputsWithKeys: inputsWithKeys,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: satoshisBeingUsed,
satsPerVByte: satsPerVByte,
feeRatePerKB: selectedTxFeeRate,
- overrideFeeAmount: overrideFeeAmount,
+ minimumFeeAmount: minimumFeeAmount,
+ isSweep: true,
+ nextChangeAddress: () async => (await changeAddress()).value,
);
}
- final int vSizeForOneOutput;
- try {
- vSizeForOneOutput = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshisBeingUsed - BigInt.one],
- ),
- ),
- )).vSize!;
- } catch (e, s) {
- Logging.instance.e("vSizeForOneOutput: $e", error: e, stackTrace: s);
- rethrow;
- }
-
- final int vSizeForTwoOutPuts;
-
- BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b;
-
- try {
- vSizeForTwoOutPuts = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress, (await changeAddress()).value],
- [
- satoshiAmountToSend,
- maxBI(
- BigInt.zero,
- satoshisBeingUsed - (satoshiAmountToSend + BigInt.one),
- ),
- ],
- ),
- ),
- )).vSize!;
- } catch (e, s) {
- Logging.instance.e("vSizeForTwoOutPuts: $e", error: e, stackTrace: s);
- rethrow;
- }
-
- // Assume 1 output, only for recipient and no change
- final feeForOneOutput =
- overrideFeeAmount ??
- BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForOneOutput)
- : estimateTxFee(
- vSize: vSizeForOneOutput,
- feeRatePerKB: selectedTxFeeRate,
- ),
- );
- // Assume 2 outputs, one for recipient and one for change
- final feeForTwoOutputs =
- overrideFeeAmount ??
- BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForTwoOutPuts)
- : estimateTxFee(
- vSize: vSizeForTwoOutPuts,
- feeRatePerKB: selectedTxFeeRate,
- ),
- );
-
- Logging.instance.d("feeForTwoOutputs: $feeForTwoOutputs");
- Logging.instance.d("feeForOneOutput: $feeForOneOutput");
-
- final difference = satoshisBeingUsed - satoshiAmountToSend;
-
- Future<TxData> singleOutputTxn() async {
- Logging.instance.d('Input size: $satoshisBeingUsed');
- Logging.instance.d('Recipient output size: $satoshiAmountToSend');
- Logging.instance.d('Fee being paid: $difference sats');
- Logging.instance.d('Estimated fee: $feeForOneOutput');
- final txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- ),
- );
- return txnData.copyWith(
- fee: Amount(
- rawValue: feeForOneOutput,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: inputsWithKeys,
- );
- }
-
- // no change output required
- if (difference == feeForOneOutput) {
- Logging.instance.d('1 output in tx');
- return await singleOutputTxn();
- } else if (difference < feeForOneOutput) {
- Logging.instance.w(
- 'Cannot pay tx fee - checking for more outputs and trying again',
- );
- // try adding more outputs
- if (spendableOutputs.length > inputsBeingConsumed) {
- return coinSelection(
+ while (true) {
+ try {
+ return await _buildTransactionPayingFee(
txData: txData,
- isSendAll: isSendAll,
- additionalOutputs: additionalOutputs + 1,
- utxos: utxos,
- coinControl: coinControl,
- isSendAllCoinControlUtxos: isSendAllCoinControlUtxos,
- overrideFeeAmount: overrideFeeAmount,
+ inputsWithKeys: inputsWithKeys,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: satoshisBeingUsed,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: selectedTxFeeRate,
+ minimumFeeAmount: minimumFeeAmount,
+ isSweep: false,
+ nextChangeAddress: () async {
+ if (!(txData.type == TxType.mweb ||
+ txData.type == TxType.mwebPegOut)) {
+ await checkChangeAddressForTransactions();
+ }
+ return (await changeAddress()).value;
+ },
);
- }
- throw Exception("Insufficient balance to pay transaction fee");
- } else {
- if (difference > (feeForOneOutput + cryptoCurrency.dustLimit.raw)) {
- final changeOutputSize = difference - feeForTwoOutputs;
- // check if possible to add the change output
- if (changeOutputSize > cryptoCurrency.dustLimit.raw &&
- difference - changeOutputSize == feeForTwoOutputs) {
- if (!(txData.type == TxType.mweb ||
- txData.type == TxType.mwebPegOut)) {
- // generate new change address if current change address has been used
- await checkChangeAddressForTransactions();
- }
- final newChangeAddress = await changeAddress();
-
- BigInt feeBeingPaid = difference - changeOutputSize;
-
- // add change output
- recipientsArray.add(newChangeAddress.value);
- recipientsAmtArray.add(changeOutputSize);
-
- Logging.instance.d('2 outputs in tx');
- Logging.instance.d('Input size: $satoshisBeingUsed');
- Logging.instance.d('Recipient output size: $satoshiAmountToSend');
- Logging.instance.d('Change Output Size: $changeOutputSize');
- Logging.instance.d('Difference (fee being paid): $feeBeingPaid sats');
- Logging.instance.d('Estimated fee: $feeForTwoOutputs');
-
- TxData txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: inputsWithKeys,
- ),
- );
-
- // make sure minimum fee is accurate if that is being used
- if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) {
- final changeOutputSize = difference - BigInt.from(txnData.vSize!);
- feeBeingPaid = difference - changeOutputSize;
- recipientsAmtArray.removeLast();
- recipientsAmtArray.add(changeOutputSize);
-
- Logging.instance.d('Adjusted Input size: $satoshisBeingUsed');
- Logging.instance.d(
- 'Adjusted Recipient output size: $satoshiAmountToSend',
- );
- Logging.instance.d(
- 'Adjusted Change Output Size: $changeOutputSize',
- );
- Logging.instance.d(
- 'Adjusted Difference (fee being paid): $feeBeingPaid sats',
- );
- Logging.instance.d('Adjusted Estimated fee: $feeForTwoOutputs');
-
- txnData = await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: inputsWithKeys,
- ),
- );
- }
-
- return txnData.copyWith(
- fee: Amount(
- rawValue: feeBeingPaid,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: inputsWithKeys,
- );
- } else {
- // Something went wrong here. It either overshot or undershot the estimated fee amount or the changeOutputSize
- // is smaller than or equal to cryptoCurrency.dustLimit. Revert to single output transaction.
- Logging.instance.d('Reverting to 1 output in tx');
-
- return await singleOutputTxn();
+ } on ElectrumFeeInsufficientFunds catch (e) {
+ if (coinControl || inputsBeingConsumed >= spendableOutputs.length) {
+ throw Exception("Insufficient balance to pay transaction fee");
}
+ Logging.instance.w(
+ "Cannot pay tx fee (${e.requiredFee} sats) -"
+ " selecting more inputs and trying again",
+ );
+ // Select enough to also cover the fee the last attempt needed. The
+ // added inputs grow the transaction, so the next attempt may still
+ // fall short and raise the target again until it converges.
+ await consumeInputsFor(satoshiAmountToSend + e.requiredFee);
}
}
-
- return txData;
}
- Future<TxData> _sendAllBuilder({
+ Future<TxData> _buildTransactionPayingFee({
required TxData txData,
- required String recipientAddress,
- required BigInt satoshisBeingUsed,
required List<BaseInput> inputsWithKeys,
+ required String recipientAddress,
+ required BigInt recipientAmount,
+ required BigInt inputTotal,
required int? satsPerVByte,
required BigInt feeRatePerKB,
- BigInt? overrideFeeAmount,
+ required BigInt? minimumFeeAmount,
+ required bool isSweep,
+ required Future<String> Function() nextChangeAddress,
}) async {
- Logging.instance.d("Attempting to send all $cryptoCurrency");
- if (txData.recipients!.length != 1) {
- throw Exception("Send all to more than one recipient not yet supported");
- }
-
- BigInt feeForOneOutput;
- if (overrideFeeAmount == null) {
- final int vSizeForOneOutput = (await buildTransaction(
- inputsWithKeys: inputsWithKeys,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshisBeingUsed - BigInt.one],
- ),
- ),
- )).vSize!;
- feeForOneOutput = BigInt.from(
- satsPerVByte != null
- ? (satsPerVByte * vSizeForOneOutput)
- : estimateTxFee(
- vSize: vSizeForOneOutput,
- feeRatePerKB: feeRatePerKB,
- ),
- );
-
- if (satsPerVByte == null) {
- final roughEstimate = roughFeeEstimate(
- inputsWithKeys.length,
- 1,
- feeRatePerKB,
- ).raw;
- if (feeForOneOutput < roughEstimate) {
- feeForOneOutput = roughEstimate;
+ final BigInt dustLimit = cryptoCurrency.dustLimit.raw;
+ String? changeAddress;
+ final result = await planElectrumFee<TxData>(
+ mode: isSweep
+ ? ElectrumFeeMode.sweep
+ : txData.subtractFeeFromAmount
+ ? ElectrumFeeMode.subtractFeeFromAmount
+ : ElectrumFeeMode.fixedAmount,
+ inputTotal: inputTotal,
+ recipientAmount: recipientAmount,
+ dustLimit: dustLimit,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: feeRatePerKB,
+ minimumFeeAmount: minimumFeeAmount,
+ build: ({required recipientAmount, changeAmount}) async {
+ final addresses = [recipientAddress];
+ final amounts = [recipientAmount];
+ if (changeAmount != null) {
+ final address = changeAddress ??= await nextChangeAddress();
+ addresses.add(address);
+ amounts.add(changeAmount);
}
- }
- } else {
- feeForOneOutput = overrideFeeAmount;
- }
-
- late TxData data;
- if (txData.type == TxType.mwebPegIn) {
- while (true) {
- final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput;
- if (satoshiAmountToSend.isNegative) {
- throw Exception(
- "Estimated fee ($feeForOneOutput sats) is greater than balance!",
- );
- }
-
- data = await buildTransaction(
+ final transaction = await buildTransaction(
+ inputsWithKeys: inputsWithKeys,
txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshiAmountToSend],
- ),
+ recipients: await helperRecipientsConvert(addresses, amounts),
+ usedUTXOs: inputsWithKeys,
),
- inputsWithKeys: inputsWithKeys,
- );
-
- if (overrideFeeAmount != null) {
- break;
- }
-
- // Signing can change vSize, so calculate the fee from the final tx.
- final vSize = BigInt.from(data.vSize!);
- final feeForFinalVSize = BigInt.from(
- satsPerVByte != null
- ? satsPerVByte * data.vSize!
- : estimateTxFee(vSize: data.vSize!, feeRatePerKB: feeRatePerKB),
);
- final requiredFee = feeForFinalVSize > vSize ? feeForFinalVSize : vSize;
- if (feeForOneOutput >= requiredFee) {
- break;
- }
- feeForOneOutput = requiredFee;
- }
- } else {
- final satoshiAmountToSend = satoshisBeingUsed - feeForOneOutput;
-
- if (satoshiAmountToSend.isNegative) {
- throw Exception(
- "Estimated fee ($feeForOneOutput sats) is greater than balance!",
- );
- }
-
- data = await buildTransaction(
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- [recipientAddress],
- [satoshiAmountToSend],
- ),
- ),
- inputsWithKeys: inputsWithKeys,
- );
- }
+ return (transaction: transaction, vSize: transaction.vSize!);
+ },
+ );
- return data.copyWith(
+ return result.transaction.copyWith(
fee: Amount(
- rawValue: feeForOneOutput,
+ rawValue: result.fee,
fractionDigits: cryptoCurrency.fractionDigits,
),
usedUTXOs: inputsWithKeys,
@@ -794,33 +611,25 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
" signedSize=${selection.signedSize}",
);
- /// Add the change if there is one
- final List<String> recipientsArray = [recipientAddress];
- final List<BigInt> recipientsAmtArray = [satoshiAmountToSend];
- if (!selection.changeless) {
- await checkChangeAddressForTransactions();
- final freshChange = (await getCurrentChangeAddress())!;
- recipientsArray.add(freshChange.value);
- recipientsAmtArray.add(selection.changeValue);
- }
-
- final TxData txBuilt = await buildTransaction(
- inputsWithKeys: selectedBaseInputs,
- txData: txData.copyWith(
- recipients: await helperRecipientsConvert(
- recipientsArray,
- recipientsAmtArray,
- ),
- usedUTXOs: selectedBaseInputs,
- ),
+ final BigInt inputTotal = selectedBaseInputs.fold(
+ BigInt.zero,
+ (sum, input) => sum + input.value,
);
- return txBuilt.copyWith(
- fee: Amount(
- rawValue: selection.fee,
- fractionDigits: cryptoCurrency.fractionDigits,
- ),
- usedUTXOs: selectedBaseInputs,
+ return _buildTransactionPayingFee(
+ txData: txData,
+ inputsWithKeys: selectedBaseInputs,
+ recipientAddress: recipientAddress,
+ recipientAmount: satoshiAmountToSend,
+ inputTotal: inputTotal,
+ satsPerVByte: satsPerVByte,
+ feeRatePerKB: feeRatePerKB,
+ minimumFeeAmount: null,
+ isSweep: false,
+ nextChangeAddress: () async {
+ await checkChangeAddressForTransactions();
+ return (await getCurrentChangeAddress())!.value;
+ },
);
}
@@ -1583,6 +1392,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
final vout = jsonUTXO["tx_pos"] as int;
final outputs = txn["vout"] as List;
+ final mwebPegoutMaturity = cryptoCurrency.mwebPegoutMaturity;
+ final isMwebPegout =
+ mwebPegoutMaturity != null && isMwebPegoutOutput(outputs, vout);
String? scriptPubKey;
String? utxoOwnerAddress;
@@ -1620,6 +1432,11 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
blockHeight: jsonUTXO["height"] as int?,
blockTime: txn["blocktime"] as int?,
address: utxoOwnerAddress,
+ otherData: isMwebPegout
+ ? jsonEncode({
+ UTXOOtherDataKeys.mwebPegoutMaturity: mwebPegoutMaturity,
+ })
+ : null,
);
return utxo;
@@ -2189,30 +2006,46 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
// mweb
if (result.type.isMweb()) {
- final fee = await (this as MwebInterface).mwebFee(txData: result);
-
- TxData mwebData = await coinSelection(
- txData: result.copyWith(
- recipients: result.recipients!.where((e) => !(e.isChange)).toList(),
- ),
- utxos: utxos?.toList(),
- coinControl: coinControl,
- isSendAll: isSendAll,
- isSendAllCoinControlUtxos: isSendAllCoinControlUtxos,
- overrideFeeAmount: fee.raw,
+ final effectiveFeeRatePerKB = effectiveMwebFeeRatePerKB(
+ feeRatePerKB: result.feeRateAmount!,
+ satsPerVByte: result.satsPerVByte,
);
-
- if (mwebData.type == TxType.mwebPegIn) {
- mwebData = await buildTransaction(
- txData: mwebData,
- inputsWithKeys: mwebData.usedUTXOs!,
- );
- }
- final data = await (this as MwebInterface).processMwebTransaction(
- mwebData,
+ // Awaited so failures surface in this function's catch below.
+ return await processWithReconciledMwebFee<TxData, TxData>(
+ initial: result,
+ feeRatePerKB: effectiveFeeRatePerKB,
+ paidFee: (candidate) => candidate.fee!.raw,
+ requiredFee: (candidate, feeRatePerKB) async =>
+ (await (this as MwebInterface).mwebFee(
+ txData: candidate,
+ feeRatePerKB: feeRatePerKB,
+ )).raw,
+ rebuild: (initial, minimumFee) {
+ // Use the original recipients so send-all selects every input.
+ return coinSelection(
+ txData: initial.copyWith(recipients: txData.recipients),
+ utxos: utxos?.toList(),
+ coinControl: coinControl,
+ isSendAll: isSendAll,
+ isSendAllCoinControlUtxos: isSendAllCoinControlUtxos,
+ minimumFeeAmount: minimumFee,
+ );
+ },
+ process: (candidate, feeRatePerKB) async {
+ if (candidate.type == TxType.mwebPegIn) {
+ candidate = await buildTransaction(
+ txData: candidate,
+ inputsWithKeys: candidate.usedUTXOs!,
+ );
+ }
+ final data = await (this as MwebInterface).processMwebTransaction(
+ txData: candidate,
+ feeRatePerKB: feeRatePerKB,
+ );
+ Logging.instance.d("prepare MWEB send: $data");
+ return data;
+ },
);
- Logging.instance.d("prepare MWEB send: $data");
- return data.copyWith(fee: fee);
}
Logging.instance.d("prepare send: $result");
@@ -2271,10 +2104,7 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
}
@override
- Future<String> signMessage(
- final String message, {
- required final Address address,
- }) async {
+ Future<String> signMessage(String message, {required Address address}) async {
if (isViewOnly) {
throw Exception("Cannot sign a message in a view only wallet");
}
@@ -2295,9 +2125,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<bool> verifyMessage(
- final String message, {
- required final String address,
- required final String signature,
+ String message, {
+ required String address,
+ required String signature,
}) async {
final signed = coinlib.MessageSignature.fromBase64(signature);
@@ -2491,11 +2321,11 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
canBatch
? checkGapsBatched(
txCountBatchSize,
- root,
+ root!,
type,
receiveChain,
)
- : checkGapsLinearly(root, type, receiveChain),
+ : checkGapsLinearly(root!, type, receiveChain),
);
}
}
@@ -2515,11 +2345,11 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
canBatch
? checkGapsBatched(
txCountBatchSize,
- root,
+ root!,
type,
changeChain,
)
- : checkGapsLinearly(root, type, changeChain),
+ : checkGapsLinearly(root!, type, changeChain),
);
}
}
### lib/wallets/wallet/wallet_mixin_interfaces/mweb_fee_utils.dart
@@ -0,0 +1,61 @@
+const _kilobyte = 1000;
+final _kilobyteBigInt = BigInt.from(_kilobyte);
+
+BigInt effectiveMwebFeeRatePerKB({
+ required BigInt feeRatePerKB,
+ required int? satsPerVByte,
+}) {
+ final requestedFeeRatePerKB = satsPerVByte == null
+ ? feeRatePerKB
+ : BigInt.from(satsPerVByte) * _kilobyteBigInt;
+
+ return requestedFeeRatePerKB < _kilobyteBigInt
+ ? _kilobyteBigInt
+ : requestedFeeRatePerKB;
+}
+
+BigInt requiredMwebFee({
+ required BigInt currentFee,
+ required BigInt feeIncrease,
+ required bool hasPegin,
+ required int vSize,
+ required BigInt feeRatePerKB,
+}) {
+ if (!hasPegin) {
+ return currentFee + feeIncrease;
+ }
+
+ final vSizeBigInt = BigInt.from(vSize);
+ final rateFee =
+ (feeRatePerKB * vSizeBigInt + _kilobyteBigInt - BigInt.one) ~/
+ _kilobyteBigInt;
+ final baseFee = rateFee > vSizeBigInt ? rateFee : vSizeBigInt;
+ return baseFee + feeIncrease;
+}
+
+Future<R> processWithReconciledMwebFee<T, R>({
+ required T initial,
+ required BigInt feeRatePerKB,
+ required BigInt Function(T candidate) paidFee,
+ required Future<BigInt> Function(T candidate, BigInt feeRatePerKB)
+ requiredFee,
+ required Future<T> Function(T initial, BigInt minimumFee) rebuild,
+ required Future<R> Function(T candidate, BigInt feeRatePerKB) process,
+}) async {
+ const maxAttempts = 10;
+ var attempt = 0;
+ var candidate = initial;
+
+ while (true) {
+ final fee = await requiredFee(candidate, feeRatePerKB);
+ if (fee <= paidFee(candidate)) {
+ return process(candidate, feeRatePerKB);
+ }
+ if (++attempt > maxAttempts) {
+ throw Exception(
+ "MWEB fee calculation failed to converge after $maxAttempts attempts",
+ );
+ }
+ candidate = await rebuild(initial, fee);
+ }
+}
### lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart
@@ -29,6 +29,7 @@ import '../../isar/models/wallet_info.dart';
import '../../models/tx_data.dart';
import '../intermediate/external_wallet.dart';
import 'electrumx_interface.dart';
+import 'mweb_fee_utils.dart';
mixin MwebInterface<T extends ElectrumXCurrencyInterface>
on ElectrumXInterface<T>
@@ -440,14 +441,17 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
}
}
- Future<TxData> processMwebTransaction(TxData txData) async {
+ Future<TxData> processMwebTransaction({
+ required TxData txData,
+ required BigInt feeRatePerKB,
+ }) async {
final client = await _client;
final response = await client.create(
CreateRequest(
rawTx: txData.raw!.toUint8ListFromHex,
scanSecret: await _scanSecret,
spendSecret: await _spendSecret,
- feeRatePerKb: Int64(txData.feeRateAmount!.toInt()),
+ feeRatePerKb: Int64(feeRatePerKB.toInt()),
dryRun: false,
),
);
@@ -959,7 +963,10 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
}
}
- Future<Amount> mwebFee({required TxData txData}) async {
+ Future<Amount> mwebFee({
+ required TxData txData,
+ required BigInt feeRatePerKB,
+ }) async {
final outputs = txData.recipients!;
final utxos = txData.usedUTXOs!;
@@ -968,8 +975,7 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
final preOutputSum = outputs.fold(BigInt.zero, (p, e) => p + e.amount.raw);
final fee = sumOfUtxosValue - preOutputSum;
- final feeRate =
- txData.satsPerVByte ?? (txData.feeRateAmount!.toInt() / 1000).ceil();
+ final vBytesPerKilobyte = BigInt.from(1000);
final client = await _client;
@@ -978,7 +984,7 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
rawTx: txData.raw!.toUint8ListFromHex,
scanSecret: await _scanSecret,
spendSecret: await _spendSecret,
- feeRatePerKb: Int64(feeRate * 1000),
+ feeRatePerKb: Int64(feeRatePerKB.toInt()),
dryRun: true,
),
);
@@ -993,9 +999,8 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
(utxo) => processedTx.inputs.any(
(input) =>
input.prevOut.hash.toHex ==
- Uint8List.fromList(
- utxo.id.toUint8ListFromHex.reversed.toList(),
- ).toHex,
+ Uint8List.fromList(utxo.id.toUint8ListFromHex.reversed.toList())
+ .toHex,
),
)
.toList();
@@ -1010,11 +1015,19 @@ mixin MwebInterface<T extends ElectrumXCurrencyInterface>
BigInt feeIncrease = posOutputSum - expectedPegin;
if (expectedPegin > BigInt.zero) {
- feeIncrease += BigInt.from(feeRate * 41);
+ feeIncrease +=
+ (feeRatePerKB * BigInt.from(41) + vBytesPerKilobyte - BigInt.one) ~/
+ vBytesPerKilobyte;
}
return Amount(
- rawValue: fee + feeIncrease,
+ rawValue: requiredMwebFee(
+ currentFee: fee,
+ feeIncrease: feeIncrease,
+ hasPegin: expectedPegin > BigInt.zero,
+ vSize: txData.vSize!,
+ feeRatePerKB: feeRatePerKB,
+ ),
fractionDigits: cryptoCurrency.fractionDigits,
);
}
### lib/wallets/wallet/wallet_mixin_interfaces/nano_interface.dart
@@ -37,6 +37,25 @@ Map<String, String> _buildHeaders(String url) {
return result;
}
+({String frontier, String representative, BigInt balanceAfterSend})
+parseNanoSendState(Map<String, dynamic> accountInfo, BigInt sendAmount) {
+ if (accountInfo["error"] != null) {
+ throw Exception("account_info error: ${accountInfo["error"]}");
+ }
+ final liveBalance = BigInt.tryParse(accountInfo["balance"].toString());
+ if (liveBalance == null) {
+ throw Exception("Invalid account_info balance");
+ }
+ if (sendAmount > liveBalance) {
+ throw Exception("Insufficient balance");
+ }
+ return (
+ frontier: accountInfo["frontier"].toString(),
+ representative: accountInfo["representative"].toString(),
+ balanceAfterSend: liveBalance - sendAmount,
+ );
+}
+
mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
// since nano based coins only have a single address/account we can cache
// the address instead of fetching from db every time we need it in certain
@@ -330,9 +349,8 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
@override
Future<void> updateNode() async {
_cachedNode =
- NodeService(
- secureStorageInterface: secureStorageInterface,
- ).getPrimaryNodeFor(currency: info.coin) ??
+ NodeService(secureStorageInterface: secureStorageInterface)
+ .getPrimaryNodeFor(currency: info.coin) ??
info.coin.defaultNode(isPrimary: true);
unawaited(refresh());
@@ -341,9 +359,8 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
@override
NodeModel getCurrentNode() {
return _cachedNode ??
- NodeService(
- secureStorageInterface: secureStorageInterface,
- ).getPrimaryNodeFor(currency: info.coin) ??
+ NodeService(secureStorageInterface: secureStorageInterface)
+ .getPrimaryNodeFor(currency: info.coin) ??
info.coin.defaultNode(isPrimary: true);
}
@@ -412,12 +429,6 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final String publicAddress =
(_cachedAddress ?? await getCurrentReceivingAddress())!.value;
- // first update to get latest account balance:
-
- final currentBalance = info.cachedBalance.spendable;
- final txAmount = txData.amount!;
- final BigInt balanceAfterTx = (currentBalance - txAmount).raw;
-
// get the account info (we need the frontier and representative):
final infoBody = jsonEncode({
"action": "account_info",
@@ -435,12 +446,10 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
: null,
);
- final String frontier = jsonDecode(
- infoResponse.body,
- )["frontier"].toString();
- final String representative = jsonDecode(
- infoResponse.body,
- )["representative"].toString();
+ final accountInfo = Map<String, dynamic>.from(
+ jsonDecode(infoResponse.body) as Map,
+ );
+ final sendState = parseNanoSendState(accountInfo, txData.amount!.raw);
// link = destination address:
final String linkAsAccount = txData.recipients!.first.address;
final String link = NanoAccounts.extractPublicKey(linkAsAccount);
@@ -449,9 +458,9 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final Map<String, String> sendBlock = {
"type": "state",
"account": publicAddress,
- "previous": frontier,
- "representative": representative,
- "balance": balanceAfterTx.toString(),
+ "previous": sendState.frontier,
+ "representative": sendState.representative,
+ "balance": sendState.balanceAfterSend.toString(),
"link": link,
};
@@ -468,7 +477,7 @@ mixin NanoInterface<T extends NanoCurrency> on Bip39Wallet<T> {
final String signature = NanoSignatures.signBlock(hash, privateKey);
// get PoW for the send block:
- final String? work = await _requestWork(frontier);
+ final String? work = await _requestWork(sendState.frontier);
if (work == null) {
throw Exception("Failed to get PoW for send block");
}
### lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart
@@ -9,6 +9,7 @@ import 'package:bitcoindart/src/utils/constants/op.dart' as op;
import 'package:bitcoindart/src/utils/script.dart' as bscript;
import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib;
import 'package:isar_community/isar.dart';
+import 'package:meta/meta.dart';
import 'package:pointycastle/digests/sha256.dart';
import 'package:tuple/tuple.dart';
@@ -24,7 +25,6 @@ import '../../../utilities/bip32_utils.dart';
import '../../../utilities/bip47_utils.dart';
import '../../../utilities/enums/derive_path_type_enum.dart';
import '../../../utilities/extensions/extensions.dart';
-import '../../../utilities/format.dart';
import '../../../utilities/logger.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../../crypto_currency/interfaces/paynym_currency_interface.dart';
@@ -46,6 +46,29 @@ String _receivingPaynymAddressDerivationPath(
String _sendPaynymAddressDerivationPath(int index, {required bool testnet}) =>
"${_basePaynymDerivePath(testnet: testnet)}/0/$index";
+@visibleForTesting
+int comparePaynymNotificationUtxos(UTXO a, UTXO b) {
+ final aIsTaproot =
+ a.address?.startsWith('bc1p') == true ||
+ a.address?.startsWith('tb1p') == true;
+ final bIsTaproot =
+ b.address?.startsWith('bc1p') == true ||
+ b.address?.startsWith('tb1p') == true;
+ if (aIsTaproot != bIsTaproot) {
+ return aIsTaproot ? 1 : -1;
+ }
+ return a.blockTime!.compareTo(b.blockTime!);
+}
+
+@visibleForTesting
+void validatePaynymNotificationInputs(List<StandardInput> inputs) {
+ if (inputs.first.derivePathType == DerivePathType.bip86) {
+ throw PaynymSendException(
+ "A non-Taproot UTXO is required for a PayNym notification transaction.",
+ );
+ }
+}
+
mixin PaynymInterface<T extends PaynymCurrencyInterface>
on Bip39HDWallet<T>, ElectrumXInterface<T> {
btc_dart.NetworkType get networkType => btc_dart.NetworkType(
@@ -570,18 +593,7 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
// Sort spendable by age (oldest first), but push taproot UTXOs to the
// end since taproot inputs don't expose the raw public key needed by the
// receiver to compute ECDH for BIP47 notification parsing.
- spendableOutputs.sort((a, b) {
- final aIsTaproot =
- a.address?.startsWith('bc1p') == true ||
- a.address?.startsWith('tb1p') == true;
- final bIsTaproot =
- b.address?.startsWith('bc1p') == true ||
- b.address?.startsWith('tb1p') == true;
- if (aIsTaproot != bIsTaproot) {
- return aIsTaproot ? 1 : -1;
- }
- return b.blockTime!.compareTo(a.blockTime!);
- });
+ spendableOutputs.sort(comparePaynymNotificationUtxos);
BigInt satoshisBeingUsed = BigInt.zero;
int outputsBeingUsed = 0;
@@ -615,6 +627,8 @@ mixin PaynymInterface<T extends PaynymCurrencyInterface>
utxoObjectsToUse.map((e) => StandardInput(e)).toList(),
)).whereType<StandardInput>().toList();
+ validatePaynymNotificationInputs(inputsWithKeys);
+
final vSizeForNoChange = BigInt.from(
(await _createNotificationTx(
targetPaymentCodeString: targetPaymentCodeString,
### lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart
@@ -1,14 +1,11 @@
import '../../../models/isar/models/blockchain_data/address.dart';
mixin SignVerifyInterface {
- Future<String> signMessage(
- final String message, {
- required final Address address,
- });
+ Future<String> signMessage(String message, {required Address address});
Future<bool> verifyMessage(
- final String message, {
- required final String address,
- required final String signature,
+ String message, {
+ required String address,
+ required String signature,
});
}
### lib/widgets/dialogs/frost/frost_step_qr_dialog.dart
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart';
-
import 'package:share_plus/share_plus.dart';
import '../../../notifications/show_flush_bar.dart';
@@ -94,9 +93,11 @@ class _FrostStepQrDialogState extends State<FrostStepQrDialog> {
final file = await File("${tempDir.path}/qrcode.png").create();
await file.writeAsBytes(pngBytes);
- await Share.shareFiles(
- ["${tempDir.path}/qrcode.png"],
- text: "Receive URI QR Code",
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [XFile("${tempDir.path}/qrcode.png")],
+ text: "Receive URI QR Code",
+ ),
);
}
} catch (e) {
@@ -130,10 +131,7 @@ class _FrostStepQrDialogState extends State<FrostStepQrDialog> {
),
),
const SizedBox(height: 8),
- Text(
- widget.title,
- style: STextStyles.w600_12(context),
- ),
+ Text(widget.title, style: STextStyles.w600_12(context)),
const SizedBox(height: 8),
RoundedContainer(
color: Theme.of(context)
@@ -146,9 +144,7 @@ class _FrostStepQrDialogState extends State<FrostStepQrDialog> {
ConditionalParent(
condition: Util.isDesktop,
builder: (child) => ConstrainedBox(
- constraints: const BoxConstraints(
- maxWidth: 360,
- ),
+ constraints: const BoxConstraints(maxWidth: 360),
child: child,
),
child: Padding(
@@ -174,10 +170,7 @@ class _FrostStepQrDialogState extends State<FrostStepQrDialog> {
),
),
),
- if (!Util.isDesktop)
- const SizedBox(
- height: 16,
- ),
+ if (!Util.isDesktop) const SizedBox(height: 16),
if (!Util.isDesktop)
Row(
children: [
### lib/widgets/eth_fee_form.dart
[binary or diff unavailable]
### lib/widgets/textfields/adaptive_text_field.dart
[binary or diff unavailable]
### lib/widgets/textfields/exchange_textfield.dart
[binary or diff unavailable]
### lib/wl_gen/interfaces/lib_spark_interface.dart
[binary or diff unavailable]
### lib/wl_gen/interfaces/libepiccash_interface.dart
[binary or diff unavailable]
### lib/wl_gen/interfaces/libmwc_interface.dart
[binary or diff unavailable]
### macos/Podfile
[binary or diff unavailable]
### pubspec.lock
[binary or diff unavailable]
### scripts/android/build_all.sh
[binary or diff unavailable]
### scripts/android/config.sh
[binary or diff unavailable]
### scripts/android/download_all.sh
[binary or diff unavailable]
### scripts/app_config/configure_campfire.sh
[binary or diff unavailable]
### scripts/app_config/configure_stack_duo.sh
[binary or diff unavailable]
### scripts/app_config/configure_stack_wallet.sh
[binary or diff unavailable]
### scripts/app_config/platforms/android/platform_config.sh
[binary or diff unavailable]
### scripts/app_config/platforms/linux/platform_config.sh
[binary or diff unavailable]
### scripts/app_config/platforms/windows/platform_config.sh
[binary or diff unavailable]
### scripts/app_config/templates/android/app/build.gradle
[binary or diff unavailable]
### scripts/app_config/templates/linux/CMakeLists.txt
[binary or diff unavailable]
### scripts/app_config/templates/macos/Runner.xcodeproj/project.pbxproj
[binary or diff unavailable]
### scripts/app_config/templates/pubspec.template.yaml
[binary or diff unavailable]
### scripts/app_config/templates/windows/CMakeLists.txt
[binary or diff unavailable]
### scripts/build_app.sh
[binary or diff unavailable]
### scripts/dev/build_runner.sh
[binary or diff unavailable]
### scripts/ensure_test_app_config.sh
[binary or diff unavailable]
### scripts/ios/build_all.sh
[binary or diff unavailable]
### scripts/ios/download_all.sh
[binary or diff unavailable]
### scripts/linux/build_all.sh
[binary or diff unavailable]
### scripts/linux/download_all.sh
[binary or diff unavailable]
### scripts/macos/build_all.sh
[binary or diff unavailable]
### scripts/macos/download_all.sh
[binary or diff unavailable]
### scripts/rust_version.sh
[binary or diff unavailable]
### scripts/windows/build_all.sh
[binary or diff unavailable]
### scripts/windows/deps.sh
[binary or diff unavailable]
### scripts/windows/download_all.sh
[binary or diff unavailable]
### test/address_utils_test.dart
[binary or diff unavailable]
### test/cached_electrumx_test.mocks.dart
[binary or diff unavailable]
### test/electrumx_test.dart
[binary or diff unavailable]
### test/flutter_secure_storage_interface_test.mocks.dart
[binary or diff unavailable]
### test/models/exchange/incomplete_exchange_test.dart
[binary or diff unavailable]
### test/models/isar/mweb_pegout_test.dart
[binary or diff unavailable]
### test/models/node_model_backup_test.dart
[binary or diff unavailable]
### test/pages/cakepay/cakepay_order_view_test.dart
[binary or diff unavailable]
### test/pages/exchange_view/exchange_form_debounce_test.dart
[binary or diff unavailable]
### test/pages/exchange_view/exchange_rate_sort_test.dart
[binary or diff unavailable]
### test/pages/send_view/sol_token_amount_parsing_test.dart
[binary or diff unavailable]
### test/pages/wallet_view/transaction_views/transaction_search_filter_view_test.dart
[binary or diff unavailable]
### test/pages_desktop_specific/desktop_exchange/desktop_trade_details_presenter_test.dart
[binary or diff unavailable]
### test/pages_desktop_specific/wallet/desktop_token_amount_parsing_test.dart
[binary or diff unavailable]
### test/paynym_p2tr_test.dart
[binary or diff unavailable]
### test/price_test.dart
[binary or diff unavailable]
### test/screen_tests/onboarding/create_pin_view_screen_test.dart
[binary or diff unavailable]
### test/services/coins/bitcoin/bitcoin_wallet_test.mocks.dart
[binary or diff unavailable]
### test/services/coins/bitcoincash/bitcoincash_wallet_test.mocks.dart
[binary or diff unavailable]
### test/services/coins/dogecoin/dogecoin_wallet_test.mocks.dart
[binary or diff unavailable]
### test/services/coins/namecoin/namecoin_wallet_test.mocks.dart
[binary or diff unavailable]
### test/services/coins/particl/particl_wallet_test.mocks.dart
[binary or diff unavailable]
### test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart
[binary or diff unavailable]
### test/services/exchange/nanswap_exchange_test.dart
[binary or diff unavailable]
### test/utilities/amount/amount_field_relocalization_test.dart
[binary or diff unavailable]
### test/utilities/amount/amount_unit_test.dart
[binary or diff unavailable]
### test/utilities/desktop_password_service_test.dart
[binary or diff unavailable]
### test/utilities/extra_id_currency_support_test.dart
[binary or diff unavailable]
### test/utilities/fee_rate_type_enum_test.dart
[binary or diff unavailable]
### test/utilities/integer_input_test.dart
[binary or diff unavailable]
### test/utilities/node_uri_util_test.dart
[binary or diff unavailable]
### test/wallets/dash_policy_test.dart
[binary or diff unavailable]
### test/wallets/electrum_fee_planner_test.dart
[binary or diff unavailable]
### test/wallets/epiccash_routing_test.dart
[binary or diff unavailable]
### test/wallets/ethereum_fee_caps_test.dart
[binary or diff unavailable]
### test/wallets/ethereum_replaced_transaction_test.dart
[binary or diff unavailable]
### test/wallets/firo_address_validation_test.dart
[binary or diff unavailable]
### test/wallets/firo_transaction_type_test.dart
[binary or diff unavailable]
### test/wallets/mweb_fee_utils_test.dart
[binary or diff unavailable]
### test/wallets/nano_interface_test.dart
[binary or diff unavailable]
### test/wallets/peercoin_policy_test.dart
[binary or diff unavailable]
### test/wallets/restore_progress_test.dart
[binary or diff unavailable]
### test/wallets/tx_data_test.dart
[binary or diff unavailable]
### test/wallets/xelis_event_batcher_test.dart
[binary or diff unavailable]
### test/wallets/xelis_operation_coordinator_test.dart
[binary or diff unavailable]
### test/widget_tests/desktop/wallet_keys_desktop_popup_test.dart
[binary or diff unavailable]
### test/widgets/adaptive_text_field_test.dart
[binary or diff unavailable]
### test/widgets/eth_fee_form_test.dart
[binary or diff unavailable]
### tool/build_standalone_mwebd_windows.dart
[binary or diff unavailable]
### tool/process_pubspec_deps.dart
[binary or diff unavailable]
### tool/wl_templates/EPIC_libepiccash_interface_impl.template.dart
[binary or diff unavailable]
### tool/wl_templates/MWC_libmwc_interface_impl.template.dart
[binary or diff unavailable]Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.